Initial commit - copied over from GitLab

This commit is contained in:
os222 2025-06-21 21:22:52 +01:00
commit 4e94982262
71 changed files with 6956 additions and 0 deletions

3
.env Normal file
View File

@ -0,0 +1,3 @@
DB_USER=user
DB_PASSWORD=password
DB_NAME=main

39
.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
DRP-App/node_modules/
DRP-App/package-lock.json

44
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,44 @@
image: node:22.16.0
variables:
ANDROID_SDK_ROOT: "/sdk"
JAVA_HOME: "/usr/lib/jvm/openjdk-17"
EAS_NO_VCS: "1"
CI: "true"
cache:
paths:
- node_modules/
stages:
- restart_docker
- deploy
- test
- build
restart_docker:
stage: restart_docker
script:
- sudo docker compose down
deploy_backend:
stage: deploy
image: docker:latest
script:
- sudo docker compose up --build -d
build_android:
stage: build
script:
- cd DRP-App/
- npm install
- export EXPO_TOKEN=$EXPO_TOKEN
# - eas build -p android --non-interactive
artifacts:
paths:
- dist/*.apk
expire_in: 1 week

13
DRP-App/.easignore Normal file
View File

@ -0,0 +1,13 @@
# Ignore system/user/temp directories
AppData/
*.tmp
*.log
# Node & cache
node_modules/
npm-debug.log
.expo/
.expo-shared/
dist/
build/
coverage/

43
DRP-App/.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
# Build and system generated
.expo/
.expo-shared/
DRP-App/node_modules/
.vscode/
# Environment and credentials
.env
.env.*
# Build outputs (if any)
dist/
build/
web-build/
# OS files
.DS_Store
Thumbs.db
# IDE/Editor
.idea/
*.log
# TypeScript
*.tsbuildinfo
# Optional cache
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Expo specific
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli

50
DRP-App/README.md Normal file
View File

@ -0,0 +1,50 @@
# Welcome to your Expo app 👋
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
## Get started
1. Install dependencies
```bash
npm install
```
2. Start the app
```bash
npx expo start
```
In the output, you'll find options to open the app in a
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Get a fresh project
When you're ready, run:
```bash
npm run reset-project
```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
## Learn more
To learn more about developing your project with Expo, look at the following resources:
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
## Join the community
Join our community of developers creating universal apps.
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.

31
DRP-App/app.config.js Normal file
View File

@ -0,0 +1,31 @@
export default ({ config }) => ({
// 1) spread in everything from app.json under "expo"
...config,
// 2) inject the googleMaps.apiKey from the env
android: {
// keep all existing android fields (adaptiveIcon, edgeToEdgeEnabled, package, permissions)
...config.android,
config: {
...(config.android?.config || {}),
googleMaps: {
apiKey: process.env.ANDROID_MAPS_API_KEY || 'AIzaSyB1WZHDqjGk696AmVw7tA2sMAuOurt552Q' // When running dev mode or starting apk build locally, this will need to be replaced with the actual API key
} // You can get this from CI/CD variables
},
// Permissions (unchanged)
permissions: config.android?.permissions || [
'ACCESS_FINE_LOCATION',
'ACCESS_COARSE_LOCATION'
]
},
ios: {
// keep everything in iOS as-is, but inject googleMapsApiKey
...config.ios,
config: {
...(config.ios?.config || {}),
googleMapsApiKey: process.env.IOS_MAPS_API_KEY || ''
}
},
});

64
DRP-App/app.json Normal file
View File

@ -0,0 +1,64 @@
{
"expo": {
"name": "prayer-space-finder",
"slug": "prayer-space-finder",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/logo.jpeg",
"scheme": "prayerspacefinder",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.anonymous.prayerspacefinder",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"package": "com.anonymous.prayerspacefinder",
"permissions": [
"ACCESS_FINE_LOCATION",
"ACCESS_COARSE_LOCATION"
]
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
["expo-build-properties", {
"android": {
"usesCleartextTraffic": true
}
}],
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff"
}
],
"expo-secure-store"
],
"experiments": {
"typedRoutes": true
},
"extra": {
"router": {},
"eas": {
"projectId": "8c9da06e-de9b-4251-843f-c86aea8cfc27"
}
},
"owner": "sawalha04"
}
}

View File

@ -0,0 +1,61 @@
import { HapticTab } from '@/components/HapticTab';
import { IconSymbol } from '@/components/ui/IconSymbol';
import TabBarBackground from '@/components/ui/TabBarBackground';
import { Colors } from '@/constants/Colors';
import { Tabs } from 'expo-router';
import React from 'react';
import { Platform, useColorScheme } from 'react-native';
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'dark'].tint,
headerShown: false,
tabBarButton: HapticTab,
tabBarBackground: TabBarBackground,
tabBarStyle: Platform.select({
ios: {
// Use a transparent background on iOS to show the blur effect
position: 'absolute',
},
android: {
// Use a solid background on Android
backgroundColor: Colors[colorScheme ?? 'dark'].background,
},
default: {},
}),
}}>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
}}
/>
<Tabs.Screen
name="map"
options={{
title: 'map',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="map.fill" color={color} />,
}}
/>
<Tabs.Screen
name="mapList"
options={{
title: 'Prayer Spaces',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="chevron.left.forwardslash.chevron.right" color={color} />,
}}
/>
<Tabs.Screen
name="prayTime"
options={{
title: 'Prayer Times',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="clock.fill" color={color} />,
}}
/>
</Tabs>
);
}

View File

@ -0,0 +1,114 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Image, ScrollView } from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function HomeScreen() {
const router = useRouter();
return (
<ScrollView contentContainerStyle={styles.container}>
<Image source={require('../../assets/images/logo.jpeg')} style={styles.logo} />
<Text style={styles.title}>Welcome to Sajidaat</Text>
<Text style={styles.subtitle}>
Discover, review, and share prayer spaces near you. Find mosques, prayer rooms, and community spaces with real user reviews and photos.
</Text>
<TouchableOpacity style={styles.button} onPress={() => router.push('/(tabs)/mapList')}>
<Ionicons name="list" size={24} color="#fff" style={{ marginRight: 8 }} />
<Text style={styles.buttonText}>View Prayer Spaces List</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.button} onPress={() => router.push('/(tabs)/map')}>
<Ionicons name="map" size={24} color="#fff" style={{ marginRight: 8 }} />
<Text style={styles.buttonText}>Explore Map</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.button} onPress={() => router.push('/(tabs)/prayTime')}>
<Ionicons name="time" size={24} color="#fff" style={{ marginRight: 8 }} />
<Text style={styles.buttonText}>Check Prayer Times</Text>
</TouchableOpacity>
<View style={styles.infoBox}>
<Text style={styles.infoTitle}>How it works:</Text>
<Text style={styles.infoText}> Browse a map or list of prayer spaces</Text>
<Text style={styles.infoText}> See reviews, photos, and facilities</Text>
<Text style={styles.infoText}> Add your own reviews and photos</Text>
<Text style={styles.infoText}> Help others find the best places to pray</Text>
</View>
{/* <Text style={styles.footer}>Made with <Ionicons name="heart" size={16} color="#e74c3c" /> by the DRP Project Team</Text> */}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
backgroundColor: '#FFEEE7',
},
logo: {
width: 100,
height: 100,
marginBottom: 24,
borderRadius: 20,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#007bff',
marginBottom: 10,
textAlign: 'center',
},
subtitle: {
fontSize: 16,
color: '#555',
marginBottom: 24,
textAlign: 'center',
paddingHorizontal: 10,
},
button: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#007bff',
paddingVertical: 14,
paddingHorizontal: 24,
borderRadius: 8,
marginVertical: 8,
width: '90%',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
buttonText: {
color: '#fff',
fontSize: 18,
fontWeight: '600',
},
infoBox: {
backgroundColor: '#E2A593', // changed from #FFEEE7 to #E2A593 for filter/info box
borderRadius: 10,
padding: 18,
marginTop: 28,
marginBottom: 16,
width: '100%',
},
infoTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#333',
marginBottom: 8,
},
infoText: {
fontSize: 15,
color: '#555',
marginBottom: 4,
},
footer: {
fontSize: 14,
color: '#888',
marginTop: 16,
textAlign: 'center',
},
});

938
DRP-App/app/(tabs)/map.tsx Normal file
View File

@ -0,0 +1,938 @@
// map.tsx
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { StyleSheet, View, Text, ActivityIndicator, Platform, Linking, TouchableOpacity, Modal, Dimensions, ScrollView, Image, Alert } from 'react-native';
import MapView, { Camera, Marker, Region } from 'react-native-maps';
import { useLocalSearchParams, useFocusEffect, useRouter } from 'expo-router';
import * as Location from 'expo-location';
import { Ionicons } from '@expo/vector-icons';
import SearchComponent from '../SearchComponent'; // Import the new SearchComponent
import { getListItemStyle } from './mapList';
// Interface for prayer spaces on map (MATCHES BACKEND'S CAPITALIZED KEYS AND ALL REQUIRED FIELDS)
interface MapPrayerSpace {
ID: string;
Name: string;
Latitude: number;
Longitude: number;
Address: string;
LocationType: 'Mosque' | 'Prayer Room' | 'Community Space' | 'other';
WomensSpace: boolean;
Wudu: boolean;
OpeningHours: string;
Notes?: string;
Website?: string;
Clean: number;
CleanWudu: number;
Quiet: number;
Privateness: number;
ChildFriendly: number;
Safe: number;
NumberOfReviews: number;
Reviews: any[];
Images: any[];
}
// ImageObject and Review Interfaces (copied from PrayerSpacesListPage for consistency)
export interface ImageObject {
id?: string;
url: string;
note?: string;
}
export interface Review {
ID: string;
Rating?: number;
Quiet: number;
Clean: number;
Private: number;
Comment?: string;
User?: string;
CleanWudu?: number;
ChildFriendly?: number;
Safe?: number;
}
const KAABA_COORDS = {
latitude: 21.4225,
longitude: 39.8262,
};
const BACKEND_URL = 'http://132.145.65.145:8080'; // Your backend URL
const defaultRegion: Region = {
latitude: 51.5074, // London
longitude: -0.1278,
latitudeDelta: 0.0922,
longitudeDelta: 0.0421,
};
const TARGET_LATITUDE_DELTA = 0.005; // Zoom level for specific locations
const TARGET_LONGITUDE_DELTA = 0.005;
const USER_LATITUDE_DELTA = 0.02; // Zoom level for user's location
const USER_LONGITUDE_DELTA = 0.02;
// Image URL Conversion (copied from PrayerSpacesListPage)
function convertToFullImageUrl(imageUrl: string): string {
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
return imageUrl;
}
return `http://132.145.65.145:8080${imageUrl}`;
}
// Emoji Rating Helper (copied from PrayerSpacesListPage)
function averageRatingToEmoji(avg: number) {
if (avg < 1.66) {
return "😞";
} else if (avg < 2.33) {
return "😐";
} else {
return "😊";
}
}
// Prayer Space Type Map (copied from PrayerSpacesListPage)
function prayerSpaceTypeMap(str: string) {
switch (str) {
case "mosque":
return "Mosque"
default:
return "Other"
}
}
export default function MapScreen() {
const params = useLocalSearchParams<{ latitude?: string; longitude?: string; name?: string }>();
const router = useRouter();
// mapRegionToSet now primarily drives the initial position via initialRegion
const [mapRegionToSet, setMapRegionToSet] = useState<Region | null>(null);
const [userCoords, setUserCoords] = useState<Location.LocationObjectCoords | null>(null);
const [permissionStatus, setPermissionStatus] = useState<Location.PermissionStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [mapBearing, setMapBearing] = useState<number>(0);
const [prayerSpaces, setPrayerSpaces] = useState<MapPrayerSpace[]>([]);
const updateBearingTimeoutRef = useRef<number | null>(null);
const [isInfoModalVisible, setIsInfoModalVisible] = useState(false);
const [selectedMosqueInfo, setSelectedMosqueInfo] = useState<MapPrayerSpace | null>(null);
const mapRef = useRef<MapView>(null);
const hasAppliedInitialRegionLogic = useRef(false);
const fetchPrayerSpaces = useCallback(async () => {
try {
const response = await fetch(`${BACKEND_URL}/places/query`);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const rawData: any[] = await response.json();
const processedData: MapPrayerSpace[] = rawData.map(item => ({
ID: item.ID,
Name: item.Name,
Latitude: item.Latitude,
Longitude: item.Longitude,
Address: item.Address,
LocationType: prayerSpaceTypeMap(item.LocationType),
WomensSpace: item.WomensSpace,
Wudu: item.Wudu,
OpeningHours: item.OpeningHours || '',
Notes: item.Notes,
Website: item.WebsiteURL,
Reviews: (item.Reviews || []).map((r: any) => ({
ID: r.ID,
Rating: (r.Quiet + r.Clean + r.Private) / 3.0,
Quiet: r.Quiet, Clean: r.Clean, Private: r.Private,
CleanWudu: r.CleanWudu, ChildFriendly: r.ChildFriendly, Safe: r.Safe,
Comment: r.Comment, User: r.User,
})),
Images: (item.Images || []).map((img: any) => ({
id: img.ID,
url: convertToFullImageUrl(img.ImageURL),
note: img.Notes,
})),
Clean: item.Reviews && item.Reviews.length > 0 ? item.Reviews.reduce((sum: number, r: any) => sum + (r.Clean || 0), 0) / item.Reviews.length : 0,
CleanWudu: item.Reviews && item.Reviews.length > 0 ? item.Reviews.reduce((sum: number, r: any) => sum + (r.CleanWudu || 0), 0) / item.Reviews.length : 0,
Quiet: item.Reviews && item.Reviews.length > 0 ? item.Reviews.reduce((sum: number, r: any) => sum + (r.Quiet || 0), 0) / item.Reviews.length : 0,
Privateness: item.Reviews && item.Reviews.length > 0 ? item.Reviews.reduce((sum: number, r: any) => sum + (r.Private || 0), 0) / item.Reviews.length : 0,
ChildFriendly: item.Reviews && item.Reviews.length > 0 ? item.Reviews.reduce((sum: number, r: any) => sum + (r.ChildFriendly || 0), 0) / item.Reviews.length : 0,
Safe: item.Reviews && item.Reviews.length > 0 ? item.Reviews.reduce((sum: number, r: any) => sum + (r.Safe || 0), 0) / item.Reviews.length : 0,
NumberOfReviews: item.Reviews ? item.Reviews.length : 0,
}));
console.log('[DEBUG] Fetched & processed prayer spaces for map:', processedData);
setPrayerSpaces(processedData);
} catch (error) {
console.error('Failed to fetch prayer spaces for map:', error);
}
}, []);
useFocusEffect(
useCallback(() => {
fetchPrayerSpaces();
return () => {
// Optional cleanup
};
}, [fetchPrayerSpaces])
);
const getAdjustedQiblaDirection = (): number => {
return (0 - mapBearing + 360) % 360;
};
const PrayerSpaceMarker = () => (
<View style={styles.prayerMarker}>
<Ionicons name="location-sharp" size={30} color="#007bff" />
</View>
);
// 1. Effect for Location Permission and Fetching User Coords (runs once)
useEffect(() => {
let isMounted = true;
const requestLocation = async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (isMounted) setPermissionStatus(status);
if (status === 'granted') {
try {
const location = await Location.getLastKnownPositionAsync();
if (isMounted && location) {
setMapRegionToSet({
latitude: location.coords.latitude,
longitude: location.coords.longitude,
latitudeDelta: USER_LATITUDE_DELTA,
longitudeDelta: USER_LONGITUDE_DELTA,
});
setUserCoords(location.coords);
}
} catch (error) {
console.error("Error fetching user location:", error);
}
}
};
requestLocation();
return () => {
isMounted = false;
};
}, []);
// Handler for when a place is selected from SearchComponent
// This now *only* calls animateToRegion, not setMapRegionToSet.
const handleSelectedPlaceFromSearch = useCallback((lat: number, lng: number, name: string) => {
const newRegion: Region = {
latitude: lat,
longitude: lng,
latitudeDelta: TARGET_LATITUDE_DELTA,
longitudeDelta: TARGET_LONGITUDE_DELTA,
};
if (mapRef.current) {
mapRef.current.animateToRegion(newRegion, 1000); // Direct animation
} else {
console.warn("Map reference not available to animate to selected place.");
}
// IMPORTANT: DO NOT set mapRegionToSet here if you want animateToRegion to be the sole driver for dynamic movement.
// The map will still correctly move via animateToRegion.
}, []);
// 2. Main effect to determine the map region based on params or user location
// This useFocusEffect is primarily for setting the *initial* region for `initialRegion` prop.
useFocusEffect(
useCallback(() => {
let isMounted = true;
setIsLoading(true);
hasAppliedInitialRegionLogic.current = false;
let newRegion: Region | null = null;
if (params.latitude && params.longitude) {
const targetLat = parseFloat(params.latitude);
const targetLng = parseFloat(params.longitude);
if (!isNaN(targetLat) && !isNaN(targetLng)) {
newRegion = {
latitude: targetLat,
longitude: targetLng,
latitudeDelta: TARGET_LATITUDE_DELTA,
longitudeDelta: TARGET_LONGITUDE_DELTA,
};
}
} else if (userCoords) {
newRegion = {
latitude: userCoords.latitude,
longitude: userCoords.longitude,
latitudeDelta: USER_LATITUDE_DELTA,
longitudeDelta: USER_LONGITUDE_DELTA,
};
} else if (permissionStatus && permissionStatus !== 'granted') {
newRegion = defaultRegion;
} else if (permissionStatus === 'granted' && !userCoords && !params.latitude) {
// Permission granted, but still waiting for userCoords. Keep loading.
} else if (!permissionStatus) {
// Permission status not yet determined. Keep loading.
} else {
// Any other unhandled case, fallback to default.
newRegion = defaultRegion;
}
if (newRegion && isMounted) {
setMapRegionToSet(newRegion); // Set state for initialRegion
hasAppliedInitialRegionLogic.current = true;
}
if (isMounted &&
(newRegion ||
(permissionStatus && permissionStatus !== 'granted')
|| (permissionStatus === 'granted' && !userCoords && !params.latitude)
)
)
{
if (permissionStatus === 'granted' && !userCoords && !params.latitude) {
// Still waiting for user location to be fetched by the other useEffect
} else {
setIsLoading(false);
}
}
return () => { isMounted = false; };
}, [params.latitude, params.longitude, userCoords, permissionStatus])
);
// This useEffect ensures the map animates initially, based on mapRegionToSet.
// It is NOT used for subsequent search-initiated animations.
useEffect(() => {
if (mapRegionToSet && mapRef.current && hasAppliedInitialRegionLogic.current) {
mapRef.current.animateToRegion(mapRegionToSet, 1000);
// Reset this flag so it only animates once on initial load/focus
hasAppliedInitialRegionLogic.current = false;
}
}, [mapRegionToSet]);
const openDirections = (lat: number, lng: number, placeName?: string) => {
const scheme = Platform.select({ ios: 'maps:0,0?q=', android: 'geo:0,0?q=' });
const latLng = `${lat},${lng}`;
const label = placeName || 'Destination';
const url = Platform.select({ios: `${scheme}${label}@${latLng}`, android: `${scheme}${latLng}(${label})`});
if (url) Linking.openURL(url).catch((err: any) => console.error("Failed to open maps link:", err));
};
const handleAddMosque = () => {
router.push('/add_mosque');
};
const handleMapPress = async (event: any) => {
const { coordinate } = event.nativeEvent;
console.log(coordinate);
let address = '';
try {
const geocode = await Location.reverseGeocodeAsync({
latitude: coordinate.latitude,
longitude: coordinate.longitude,
});
if (geocode && geocode.length > 0) {
const g = geocode[0];
address = [g.name, g.street, g.city, g.region, g.postalCode, g.country].filter(Boolean).join(', ');
}
} catch (err) {
console.warn('Reverse geocoding failed:', err);
}
router.push(`/add_mosque?lat=${coordinate.latitude}&lng=${coordinate.longitude}&address=${encodeURIComponent(address)}`);
};
const handlePoiClick = async (event: any) => {
const { coordinate, name } = event.nativeEvent;
let address = '';
let mainName = name;
if (typeof name === 'string') {
mainName = name.split(/\n/)[0].trim();
}
try {
const geocode = await Location.reverseGeocodeAsync({
latitude: coordinate.latitude,
longitude: coordinate.longitude,
});
if (geocode && geocode.length > 0) {
const g = geocode[0];
address = [g.name, g.street, g.city, g.region, g.postalCode, g.country].filter(Boolean).join(', ');
}
} catch (err) {
address = mainName;
}
router.push(`/add_mosque?lat=${coordinate.latitude}&lng=${coordinate.longitude}&address=${encodeURIComponent(address)}&name=${encodeURIComponent(mainName)}`);
};
// handleMarkerPress to open info modal
const handleMarkerPress = (place: MapPrayerSpace) => {
setSelectedMosqueInfo(place);
setIsInfoModalVisible(true);
};
if (isLoading && !mapRegionToSet) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#007bff" />
<Text>Loading map...</Text>
</View>
);
}
// Use mapRegionToSet for initialRegion, fallback to default if null
const currentMapRegion = mapRegionToSet || defaultRegion;
const updateMapBearing = async () => {
if (mapRef.current) {
try {
const camera = await mapRef.current.getCamera();
setMapBearing(camera.heading || 0);
} catch (error) {
console.log('Error getting camera:', error);
}
}
};
const debouncedUpdateMapBearing = () => {
if (updateBearingTimeoutRef.current) {
clearTimeout(updateBearingTimeoutRef.current);
}
updateBearingTimeoutRef.current = setTimeout(() => {
updateMapBearing();
}, 20); // Update every 100ms max during interaction
};
return (
<View style={styles.container}>
{/* Search Bar is now a separate component */}
<SearchComponent onSelectPlace={handleSelectedPlaceFromSearch} />
{/* MapView: Uses initialRegion for initial position, then animateToRegion for movement */}
<MapView
ref={mapRef}
style={styles.map}
initialRegion={currentMapRegion} // Reverted to initialRegion
showsUserLocation
showsMyLocationButton
onPress={handleMapPress}
onPoiClick={handlePoiClick}
onRegionChange={() => {
debouncedUpdateMapBearing();
}}
onRegionChangeComplete={() => {
updateMapBearing();
}}>
{prayerSpaces.map((place) => (
<Marker
key={place.ID}
coordinate={{ latitude: place.Latitude, longitude: place.Longitude }}
title={place.Name}
onPress={() => handleMarkerPress(place)}
anchor={{ x: 0.5, y: 1 }}
>
<PrayerSpaceMarker />
</Marker>
))}
{/* Remove user location marker */}
</MapView>
<View style={styles.buttonContainer}>
<TouchableOpacity style={styles.addMosqueButton} onPress={handleAddMosque}>
<Ionicons name="add" size={30} color="white" />
<Text style={styles.addMosqueText} numberOfLines={1}>
Add Prayer Space
</Text>
</TouchableOpacity>
</View>
{/* MosqueInfoModal */}
<MosqueInfoModal
visible={isInfoModalVisible}
onClose={() => setIsInfoModalVisible(false)}
mosque={selectedMosqueInfo}
/>
</View>
);
}
// --- MosqueInfoModal Component (Inline) ---
interface MosqueInfoModalProps {
visible: boolean;
onClose: () => void;
mosque: MapPrayerSpace | null;
}
const { width: modalWidth, height: modalHeight } = Dimensions.get('window');
const MosqueInfoModal: React.FC<MosqueInfoModalProps> = ({ visible, onClose, mosque }) => {
const [isReviewsModalVisible, setIsReviewsModalVisible] = useState(false);
const router = useRouter();
if (!mosque) {
return null;
}
const averageRatingToEmoji = (avg: number) => {
if (avg < 1.66) {
return "😞";
} else if (avg < 2.33) {
return "😐";
} else {
return "😊";
}
};
const openDirectionsInMaps = () => {
if (mosque.Latitude && mosque.Longitude) {
const scheme = Platform.select({ ios: 'maps:0,0?q=', android: 'geo:0,0?q=' });
const latLng = `${mosque.Latitude},${mosque.Longitude}`;
const label = mosque.Name || 'Destination';
const url = Platform.select({ios: `${scheme}${label}@${latLng}`, android: `${scheme}${latLng}(${label})`});
if (url) Linking.openURL(url).catch((err: any) => console.error("Failed to open maps link:", err));
}
};
const openWebsite = () => {
if (mosque.Website) {
const formattedWebsite = mosque.Website.startsWith('http') ? mosque.Website : `http://${mosque.Website}`;
Linking.openURL(formattedWebsite).catch((err: any) => console.error("Failed to open website link:", err));
}
};
const handleAddReviewFromModal = () => {
onClose();
router.push({ pathname: '/add_review', params: { placeId: mosque.ID, placeName: mosque.Name } });
};
const handleAddPhotoFromModal = () => {
onClose();
router.push({ pathname: '/upload', params: { placeId: mosque.ID, placeName: mosque.Name } });
};
const handleShowReviewsFromModal = () => {
setIsReviewsModalVisible(true);
};
return (
<Modal
animationType="slide"
transparent={true}
visible={visible}
onRequestClose={onClose}
>
<View style={infoModalStyles.centeredView}>
<View style={infoModalStyles.modalView}>
<TouchableOpacity style={infoModalStyles.closeButtonTopRight} onPress={onClose}>
<Ionicons name="close-circle" size={30} color="#999" />
</TouchableOpacity>
{mosque.Images && mosque.Images.length > 0 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={infoModalStyles.thumbnailScrollView}
contentContainerStyle={infoModalStyles.thumbnailScrollViewContent}
>
{mosque.Images.map((imgObj, index) => (
<Image key={imgObj.id || `modal-img-${index}`} source={{ uri: imgObj.url }} style={infoModalStyles.thumbnailImage} />
))}
</ScrollView>
)}
<Text style={infoModalStyles.listItemName}>{mosque.Name}</Text>
{mosque.Website && (
<TouchableOpacity onPress={openWebsite}>
<Text style={[infoModalStyles.listItemText, { color: 'blue', textDecorationLine: 'underline' }]}>
{mosque.Website}
</Text>
</TouchableOpacity>
)}
<Text style={infoModalStyles.infoText}>Type: {prayerSpaceTypeMap(mosque.LocationType)}</Text>
<Text style={infoModalStyles.infoText}>Address: {mosque.Address}</Text>
{mosque.OpeningHours && <Text style={infoModalStyles.infoText}>Hours: {mosque.OpeningHours}</Text>}
{mosque.NumberOfReviews === 0 ? (
<Text style={infoModalStyles.noReviewsPlaceholderText}>No Reviews</Text>
) : (
<>
<Text style={getListItemStyle(mosque.Clean)}>
Cleanliness: {typeof mosque.Clean === 'number' ? averageRatingToEmoji(mosque.Clean) : '—'}
</Text>
<Text style={getListItemStyle(mosque.CleanWudu)}>
Wudu Cleanliness: {typeof mosque.CleanWudu === 'number' ? averageRatingToEmoji(mosque.CleanWudu) : '—'}
</Text>
<Text style={getListItemStyle(mosque.Quiet)}>
Quietness: {typeof mosque.Quiet === 'number' ? averageRatingToEmoji(mosque.Quiet) : '—'}
</Text>
<Text style={getListItemStyle(mosque.Privateness)}>
Privacy: {typeof mosque.Privateness === 'number' ? averageRatingToEmoji(mosque.Privateness) : '—'}
</Text>
<Text style={getListItemStyle(mosque.ChildFriendly)}>
Child Friendliness: {typeof mosque.ChildFriendly === 'number' ? averageRatingToEmoji(mosque.ChildFriendly) : '—'}
</Text>
<Text style={getListItemStyle(mosque.Safe)}>
Safety: {typeof mosque.Safe === 'number' ? averageRatingToEmoji(mosque.Safe) : '—'}
</Text>
</>
)}
<View style={infoModalStyles.facilitiesIconsContainer}>
{mosque.WomensSpace && <Text style={infoModalStyles.facilityIcon} accessibilityLabel="Women's Space Available">🚺 Women's Space</Text>}
{mosque.Wudu && <Text style={infoModalStyles.facilityIcon} accessibilityLabel="Wudu Available">💧 Wudu Facilities</Text>}
</View>
{mosque.Notes && <Text style={infoModalStyles.infoText}>Notes: {mosque.Notes}</Text>}
<View style={infoModalStyles.buttonRowContainer}>
<TouchableOpacity
style={[infoModalStyles.splitButtonBase, infoModalStyles.splitButtonLeft]}
onPress={handleShowReviewsFromModal}
>
<Text style={infoModalStyles.splitButtonText}>Show Reviews</Text>
</TouchableOpacity>
<View style={infoModalStyles.buttonSeparator} />
<TouchableOpacity
style={[infoModalStyles.splitButtonBase, infoModalStyles.splitButtonRight]}
onPress={openDirectionsInMaps}
>
<Text style={infoModalStyles.splitButtonText}>Get Directions</Text>
</TouchableOpacity>
</View>
<TouchableOpacity style={infoModalStyles.addReviewButton} onPress={handleAddReviewFromModal}>
<Text style={infoModalStyles.addReviewButtonText}>Add a Review</Text>
</TouchableOpacity>
<TouchableOpacity style={infoModalStyles.addPhotoButton} onPress={handleAddPhotoFromModal}>
<Text style={infoModalStyles.addPhotoButtonText}>Add a Photo</Text>
</TouchableOpacity>
{/* ReviewsModal - Now inline within MosqueInfoModal */}
<ReviewsModal
visible={isReviewsModalVisible}
onClose={() => setIsReviewsModalVisible(false)}
reviews={mosque.Reviews}
spaceName={mosque.Name}
/>
</View>
</View>
</Modal>
);
};
// --- ReviewsModal Component (Inline) ---
interface ReviewsModalProps {
visible: boolean;
onClose: () => void;
reviews: Review[];
spaceName: string;
}
const ReviewsModal: React.FC<ReviewsModalProps> = ({ visible, onClose, reviews, spaceName }) => {
return (
<Modal
animationType="slide"
transparent={true}
visible={visible}
onRequestClose={onClose}
>
<View style={infoModalStyles.centeredView}>
<View style={infoModalStyles.modalView}>
<Text style={infoModalStyles.modalTitle}>Reviews for {spaceName}</Text>
<ScrollView style={infoModalStyles.infoScrollView}>
{reviews.length === 0 ? (
<Text style={infoModalStyles.noReviewsPlaceholderText}>No reviews yet.</Text>
) : (
reviews.map((review, index) => (
<View key={review.ID || `review-${index}`} style={infoModalStyles.reviewItem}>
{review.Rating !== undefined && review.Rating !== null && (
<Text style={infoModalStyles.reviewRating}>Overall Rating: {averageRatingToEmoji(review.Rating)}</Text>
)}
<Text style={infoModalStyles.reviewDetail}>Cleanliness: {typeof review.Clean === 'number' ? averageRatingToEmoji(review.Clean) : '—'}</Text>
<Text style={infoModalStyles.reviewDetail}>Wudu Cleanliness: {typeof review.CleanWudu === 'number' ? averageRatingToEmoji(review.CleanWudu) : '—'}</Text>
<Text style={infoModalStyles.reviewDetail}>Quietness: {typeof review.Quiet === 'number' ? averageRatingToEmoji(review.Quiet) : '—'}</Text>
<Text style={infoModalStyles.reviewDetail}>Privacy: {typeof review.Private === 'number' ? averageRatingToEmoji(review.Private) : '—'}</Text>
<Text style={infoModalStyles.reviewDetail}>Child Friendliness: {typeof review.ChildFriendly === 'number' ? averageRatingToEmoji(review.ChildFriendly) : '—'}</Text>
<Text style={infoModalStyles.reviewDetail}>Safety: {typeof review.Safe === 'number' ? averageRatingToEmoji(review.Safe) : '—'}</Text>
{review.Comment && <Text style={infoModalStyles.reviewComment}>"{review.Comment}"</Text>}
{review.User && <Text style={infoModalStyles.reviewUser}> {review.User}</Text>}
</View>
))
)}
</ScrollView>
<TouchableOpacity style={infoModalStyles.closeButton} onPress={onClose}>
<Text style={infoModalStyles.closeButtonText}>Close</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
};
const infoModalStyles = StyleSheet.create({
centeredView: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.6)',
},
modalView: {
margin: 20,
backgroundColor: 'white',
borderRadius: 10,
padding: 15,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
width: modalWidth * 0.9,
maxHeight: modalHeight * 0.9,
},
closeButtonTopRight: {
position: 'absolute',
top: 5,
right: 5,
zIndex: 1,
padding: 5,
},
modalTitle: {
fontSize: 22,
fontWeight: 'bold',
marginBottom: 8,
color: '#007bff',
textAlign: 'center',
},
infoScrollView: {
width: '100%',
maxHeight: 'auto',
marginBottom: 15,
},
thumbnailScrollView: {
marginBottom: 12,
maxHeight: 100,
width: '100%',
},
thumbnailScrollViewContent: {
paddingVertical: 2,
},
thumbnailImage: {
width: 100,
height: 80,
borderRadius: 6,
marginRight: 8,
backgroundColor: '#e0e0e0',
},
listItemName: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 8,
color: '#007bff',
textAlign: 'center',
},
listItemText: {
fontSize: 14,
color: '#555',
marginBottom: 4,
lineHeight: 20,
},
infoText: {
fontSize: 14,
color: '#555',
marginBottom: 4,
lineHeight: 20,
},
facilitiesIconsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
marginVertical: 8,
justifyContent: 'center',
},
facilityIcon: {
fontSize: 14,
color: '#333',
backgroundColor: '#e0e0e0',
borderRadius: 5,
paddingHorizontal: 8,
paddingVertical: 4,
margin: 4,
},
noReviewsPlaceholderText: {
fontSize: 16,
color: '#777',
textAlign: 'center',
marginVertical: 8,
fontStyle: 'italic',
},
buttonRowContainer: {
flexDirection: 'row',
marginTop: 12,
borderWidth: 1,
borderColor: '#007bff',
borderRadius: 5,
overflow: 'hidden',
width: '100%',
},
splitButtonBase: {
flex: 1,
paddingVertical: 10,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#007bff',
},
splitButtonLeft: {},
splitButtonRight: {},
buttonSeparator: {
width: 1,
backgroundColor: '#fff',
},
splitButtonText: {
color: 'white',
fontSize: 14,
fontWeight: '600',
},
addReviewButton: {
backgroundColor: '#28a745',
borderRadius: 5,
paddingVertical: 10,
alignItems: 'center',
marginTop: 10,
width: '100%',
},
addReviewButtonText: {
color: 'white',
fontSize: 15,
fontWeight: '600',
},
addPhotoButton: {
backgroundColor: '#6c757d',
borderRadius: 5,
paddingVertical: 10,
alignItems: 'center',
marginTop: 10,
width: '100%',
},
addPhotoButtonText: {
color: 'white',
fontSize: 15,
fontWeight: '600',
},
websiteText: {
fontSize: 14,
color: 'blue',
textDecorationLine: 'underline',
marginTop: 4,
textAlign: 'center',
marginBottom: 8,
},
directionsButton: {
backgroundColor: '#007bff',
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 25,
marginTop: 20,
minWidth: 150,
alignItems: 'center',
width: '100%',
},
sectionTitle: {
fontSize: 16,
fontWeight: 'bold',
marginTop: 15,
marginBottom: 8,
color: '#333',
textAlign: 'center',
},
reviewItem: {
backgroundColor: '#f9f9f9',
padding: 15,
borderRadius: 8,
marginBottom: 10,
borderWidth: 1,
borderColor: '#eee',
},
reviewRating: {
fontSize: 16,
fontWeight: 'bold',
marginBottom: 5,
color: '#007bff',
},
reviewDetail: {
fontSize: 14,
color: '#555',
marginBottom: 2,
},
reviewComment: {
fontSize: 14,
fontStyle: 'italic',
marginTop: 8,
color: '#666',
borderLeftWidth: 3,
borderLeftColor: '#ccc',
paddingLeft: 10,
},
reviewUser: {
fontSize: 12,
fontStyle: 'italic',
textAlign: 'right',
marginTop: 5,
color: '#888',
},
});
const styles = StyleSheet.create({
buttonContainer: {
position: 'absolute',
bottom: 20,
right: 20,
left: 20, // This ensures the button doesn't go edge-to-edge
alignItems: 'flex-end', // Aligns the button to the right
},
addMosqueButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#007AFF',
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 25,
elevation: 5,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
// Remove fixed width and height to allow the button to expand
minWidth: 200, // Minimum width to ensure it's not too narrow
},
addMosqueText: {
color: 'white',
marginLeft: 8, // Add some space between icon and text
fontSize: 16, // Adjust as needed
},
container: { flex: 1 },
map: { flex: 1 },
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
prayerMarker: {
width: 30,
height: 40,
alignItems: 'center',
justifyContent: 'flex-end',
paddingBottom: 5,
},
prayerDot: {
width: 16,
height: 16,
borderRadius: 8,
backgroundColor: '#1976D2',
borderWidth: 2,
borderColor: '#fff',
},
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,306 @@
import dayjs, { Dayjs } from 'dayjs';
import React, { useEffect, useState, useRef } from 'react';
import { View, Text, FlatList, StyleSheet, Alert } from 'react-native';
import * as Location from 'expo-location';
import * as Notifications from 'expo-notifications';
type PrayerTime = {
name: string;
time: string;
};
type PrayerTimeDate = {
name: string;
time: Dayjs;
};
type PrayerData = {
location: string;
date: string;
times: PrayerTime[];
timesAsDates: PrayerTimeDate[];
sunriseTime: string;
hanafiAsr: string;
};
export default function PrayersScreen() {
const [prayerData, setPrayerData] = useState<PrayerData | null>(null);
const [nextPrayer, setNextPrayer] = useState<{ name: string; countdown: string } | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const getCoords = async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
throw new Error('Permission to access location was denied');
}
const location = await Location.getCurrentPositionAsync({});
return location.coords;
};
const fetchPrayerTimes = async () => {
try {
const coords = await getCoords();
const today = dayjs();
const yyyy = today.year();
const mm = String(today.month() + 1).padStart(2, '0'); // month() is 0-based
const dd = String(today.date()).padStart(2, '0');
const formattedDate = `${yyyy}-${mm}-${dd}`;
const res = await fetch(
`https://api.aladhan.com/v1/timings/${formattedDate}?latitude=${coords.latitude}&longitude=${coords.longitude}&method=2`);
const hanafiRes = await fetch(
`https://api.aladhan.com/v1/timings/${formattedDate}?latitude=${coords.latitude}&longitude=${coords.longitude}&method=2&school=1`);
const data = await res.json();
const hanafiData = await hanafiRes.json();
const hanafiTiming = hanafiData.data.timings.Asr;
const timings = data.data.timings;
const formattedTimes = [
{ name: 'Fajr', time: timings.Fajr },
{ name: 'Dhuhr', time: timings.Dhuhr },
{ name: 'Asr', time: timings.Asr },
{ name: 'Maghrib', time: timings.Maghrib },
{ name: 'Isha', time: timings.Isha },
];
setPrayerData({
location: data.data.meta.timezone,
date: `${data.data.date.gregorian.date} / ${data.data.date.hijri.date}`,
times: formattedTimes,
timesAsDates: getPrayerDateTimes(formattedTimes),
sunriseTime: timings.Sunrise,
hanafiAsr: hanafiTiming
});
} catch (err) {
console.error('Error fetching prayer times:', err);
Alert.alert('Error', 'Failed to fetch prayer times.');
}
};
// Helper: convert prayer times into Date objects for today
const getPrayerDateTimes = (times: PrayerTime[]) => {
const now = dayjs();
return times.map(({ name, time }) => {
const [hours, minutes] = time.split(':').map(Number);
// Create a Dayjs object for today with set hours and minutes
const prayerTime = now.hour(hours).minute(minutes).second(0).millisecond(0);
return { name, time: prayerTime };
});
};
const schedulePrayerNotification = async (prayerName: string, prayerTime: dayjs.Dayjs) => {
const triggerTime = prayerTime.subtract(10, 'minute');
await Notifications.scheduleNotificationAsync({
content: {
title: `Upcoming Prayer: ${prayerName}`,
body: `Time for ${prayerName} is in 10 minutes.`,
sound: true,
},
trigger: {
type: 'date',
date: triggerTime.toDate(),
} as Notifications.DateTriggerInput,
});
};
const isCurrentPrayer = (prayerTimes: PrayerData, prayerName: string): boolean => {
const times = prayerTimes.timesAsDates;
const now = dayjs();
// Sort times just in case
times.sort((a, b) => a.time.valueOf() - b.time.valueOf());
for (let i = times.length - 1; i >= 0; i--) {
if (now.isAfter(times[i].time) || now.isSame(times[i].time)) {
// Check if the current prayer matches the prayerName
return times[i].name === prayerName;
}
}
// If none matched, compare to last prayer (optional fallback)
return times.length > 0 ? times[times.length - 1].name === prayerName : false;
};
// Find the next prayer based on current time
const findNextPrayer = (prayerTimes: PrayerTimeDate[]) => {
const now = dayjs();
// Find next prayer with time > now
const next = prayerTimes.find((p) => dayjs(p.time).isAfter(now));
if (next) {
return next;
}
// If none found, all prayers passed — fallback to first prayer tomorrow (Fajr)
// So add 1 day to first prayer time
const tomorrowFajr = prayerTimes[0].time.add(1, 'day');
return { name: prayerTimes[0].name, time: tomorrowFajr };
};
// Update countdown every second
const startCountdown = (prayerTimes: PrayerTimeDate[]) => {
if (intervalRef.current) clearInterval(intervalRef.current);
intervalRef.current = setInterval(() => {
const now = dayjs();
const next = findNextPrayer(prayerTimes);
const diff = next.time.diff(now);
// If countdown hits zero or negative, just continue to next prayer immediately
const safeDiff = diff > 0 ? diff : 0;
const hrs = String(Math.floor(safeDiff / (1000 * 60 * 60))).padStart(2, '0');
const mins = String(Math.floor((safeDiff / (1000 * 60)) % 60)).padStart(2, '0');
const secs = String(Math.floor((safeDiff / 1000) % 60)).padStart(2, '0');
setNextPrayer({
name: next.name,
countdown: `${hrs}:${mins}:${secs}`,
});
}, 1000) as any;
};
// Initial fetch
useEffect(() => {
fetchPrayerTimes();
}, []);
// Start countdown when prayerData changes
useEffect(() => {
if (prayerData?.times) {
startCountdown(prayerData.timesAsDates);
const now = dayjs();
// Only schedule notifications for prayers that haven't passed yet
prayerData.timesAsDates
.filter(prayer => prayer.time.isAfter(now))
.forEach(prayer => {
schedulePrayerNotification(prayer.name, prayer.time);
});
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [prayerData]);
return (
<View style={styles.container}>
<Text style={styles.title}>Today's Prayer Times</Text>
{prayerData && (
<>
<Text style={styles.date}>{prayerData.date}</Text>
</>
)}
{nextPrayer ? (
<Text style={styles.nextPrayer}>
Next prayer: {nextPrayer.name} in {nextPrayer.countdown}
</Text>
) : (
<Text style={styles.nextPrayer}>Calculating next prayer...</Text>
)}
{prayerData ? (
<FlatList
data={prayerData.times}
keyExtractor={(item) => item.name}
renderItem={({ item }) => {
const isCurrent = isCurrentPrayer(prayerData, item.name);
return (
<View style={[styles.card, isCurrent && styles.currentPrayerCard]}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Text style={styles.prayerName}>{item.name}</Text>
<Text style={styles.prayerTime}>{item.time}</Text>
</View>
{/* Fajr Sunrise */}
{item.name === 'Fajr' && prayerData.sunriseTime && (
<View style={styles.sunriseRow}>
<Text style={styles.subtitle}>Sunrise</Text>
<Text style={styles.subtitle}>{prayerData.sunriseTime}</Text>
</View>
)}
{/* Asr (Hanafi) */}
{item.name === 'Asr' && prayerData.hanafiAsr && (
<View style={styles.sunriseRow}>
<Text style={styles.subtitle}>Asr (Hanafi)</Text>
<Text style={styles.subtitle}>{prayerData.hanafiAsr}</Text>
</View>
)}
</View>
);
}}
contentContainerStyle={{ paddingVertical: 10 }}
/>
) : (
<Text style={{ marginTop: 20, textAlign: 'center' }}>Loading...</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20, backgroundColor: '#FFEEE7' },
title: {
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
color: '#333',
marginBottom: 6, // reduce space below title
},
sunriseRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 4,
},
subtitle: {
textAlign: 'center',
fontSize: 14,
color: '#555',
},
date: {
textAlign: 'center',
fontSize: 14,
color: '#888',
marginBottom: 2, // keep this small
},
nextPrayer: {
textAlign: 'center',
fontSize: 16,
color: '#008060',
marginTop: 4, // reduce gap above next prayer
marginBottom: 10,
fontWeight: '600',
},
card: {
padding: 15,
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
marginBottom: 12,
backgroundColor: '#fff', // changed to white
},
currentPrayerCard: {
backgroundColor: '#fff', // keep current prayer tab white too
borderColor: '#4CAF50',
borderWidth: 2,
},
specialPrayerName: {
color: '#00796b', // Teal
},
specialPrayerTime: {
fontWeight: 'bold',
},
prayerName: { fontSize: 16, fontWeight: '600' },
prayerTime: { fontSize: 16 },
});

View File

@ -0,0 +1,32 @@
import { Link, Stack } from 'expo-router';
import { StyleSheet } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<ThemedView style={styles.container}>
<ThemedText type="title">This screen does not exist.</ThemedText>
<Link href="/" style={styles.link}>
<ThemedText type="link">does this ever happen?</ThemedText>
</Link>
</ThemedView>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
link: {
marginTop: 15,
paddingVertical: 15,
},
});

View File

@ -0,0 +1,182 @@
// ImageViewerModal.tsx
import React, { useState, useRef, useEffect } from 'react';
import {
Modal,
View,
Text,
Image,
TouchableOpacity,
StyleSheet,
Dimensions,
FlatList,
SafeAreaView,
Platform
} from 'react-native';
export interface ImageObject {
url: string;
note?: string;
}
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
interface ImageViewerModalProps {
visible: boolean;
images: ImageObject[];
initialIndex?: number;
onClose: () => void;
}
const ImageViewerModal: React.FC<ImageViewerModalProps> = ({
visible,
images,
initialIndex = 0,
onClose,
}) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const flatListRef = useRef<FlatList<ImageObject>>(null);
// Effect to scroll to initialIndex when modal becomes visible or initialIndex prop changes
useEffect(() => {
if (visible && images && images.length > 0) {
const validInitialIndex = Math.max(0, Math.min(initialIndex, images.length - 1));
setCurrentIndex(validInitialIndex);
// setTimeout is a common workaround for FlatList not scrolling immediately
// to initialScrollIndex on mount or when data changes if modal was hidden.
setTimeout(() => {
flatListRef.current?.scrollToIndex({
animated: false,
index: validInitialIndex,
});
}, 100);
}
}, [visible, initialIndex, images]);
const onViewableItemsChanged = useRef(
({ viewableItems }: { viewableItems: Array<{ item: ImageObject; index: number | null }> }) => {
if (viewableItems.length > 0 && viewableItems[0].index !== null) {
setCurrentIndex(viewableItems[0].index);
}
}
).current;
const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 50 }).current;
if (!images || images.length === 0) {
return null; // Don't render modal if no images
}
const renderImageItem = ({ item }: { item: ImageObject }) => (
<View style={styles.slide}>
<Image source={{ uri: item.url }} style={styles.largeImage} resizeMode="contain" />
</View>
);
return (
<Modal
animationType="slide"
transparent={false}
visible={visible}
onRequestClose={onClose} // For Android back button
>
<SafeAreaView style={styles.modalContainer}>
<FlatList
ref={flatListRef}
data={images}
renderItem={renderImageItem}
keyExtractor={(item, index) => `${item.url}-${index}`} // Ensure unique key
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig}
// initialScrollIndex={currentIndex} // Let useEffect handle initial scroll
getItemLayout={(data, index) => (
{ length: screenWidth, offset: screenWidth * index, index }
)}
/>
{images[currentIndex]?.note && (
<View style={styles.noteContainer}>
<Text style={styles.noteText}>{images[currentIndex].note}</Text>
</View>
)}
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Text style={styles.closeButtonText}></Text>
</TouchableOpacity>
{images.length > 1 && (
<View style={styles.pagination}>
<Text style={styles.paginationText}>{currentIndex + 1} / {images.length}</Text>
</View>
)}
</SafeAreaView>
</Modal>
);
};
const styles = StyleSheet.create({
modalContainer: {
flex: 1,
backgroundColor: 'black',
},
slide: {
width: screenWidth,
height: '100%', // Make slide take full height of SafeAreaView content area
justifyContent: 'center',
alignItems: 'center',
},
largeImage: {
width: screenWidth, // Image takes full width of its slide
height: '80%', // Image takes 80% height of its slide, adjust as needed
},
noteContainer: {
position: 'absolute',
bottom: Platform.OS === 'ios' ? 20 : 40, // Adjust for different OS safe areas or tab bars if modal is within tabs
left: 0,
right: 0,
paddingHorizontal: 20,
paddingVertical: 10,
backgroundColor: 'rgba(0,0,0,0.7)',
},
noteText: {
color: 'white',
textAlign: 'center',
fontSize: 16,
},
closeButton: {
position: 'absolute',
top: Platform.OS === 'ios' ? 50 : 20, // Safer position for status bar, adjust if using custom header
right: 15,
backgroundColor: 'rgba(0,0,0,0.5)',
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
zIndex: 10, // Ensure it's above other elements
},
closeButtonText: {
color: 'white',
fontSize: 18,
fontWeight: 'bold',
lineHeight: 20, // Helps center the X
},
pagination: {
position: 'absolute',
top: Platform.OS === 'ios' ? 55 : 25,
left: 15,
backgroundColor: 'rgba(0,0,0,0.5)',
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 15,
zIndex: 10,
},
paginationText: {
color: 'white',
fontSize: 14,
},
});
export default ImageViewerModal;

View File

@ -0,0 +1,172 @@
// SearchComponent.tsx
import React, { useState, useRef, useCallback } from 'react';
import { View, TextInput, FlatList, TouchableOpacity, Text, StyleSheet, Alert, Keyboard } from 'react-native'; // Added Keyboard import
const GOOGLE_API_KEY = 'AIzaSyB1WZHDqjGk696AmVw7tA2sMAuOurt552Q'; // Your Google Places API Key
const TARGET_LATITUDE_DELTA = 0.005;
const TARGET_LONGITUDE_DELTA = 0.005;
interface SearchComponentProps {
onSelectPlace: (lat: number, lng: number, name: string) => void;
}
const SearchComponent: React.FC<SearchComponentProps> = ({ onSelectPlace }) => {
const [searchQuery, setSearchQuery] = useState('');
const [addressSuggestions, setAddressSuggestions] = useState<any[]>([]);
const debounceTimeout = useRef<NodeJS.Timeout | null>(null);
// Ref to track if a suggestion is currently being pressed
const isPressingSuggestion = useRef(false);
const handleSearch = useCallback(async (text: string) => {
setSearchQuery(text);
if (debounceTimeout.current) {
clearTimeout(debounceTimeout.current);
}
if (text.length < 3) {
setAddressSuggestions([]);
return;
}
debounceTimeout.current = setTimeout(async () => {
try {
const url = `https://maps.googleapis.com/maps/api/place/autocomplete/json?input=${encodeURIComponent(text)}&key=${GOOGLE_API_KEY}&components=country:gb&location=51.5074,-0.1278&radius=20000`;
const response = await fetch(url);
const data = await response.json();
if (data && data.predictions) {
setAddressSuggestions(data.predictions);
} else {
setAddressSuggestions([]);
}
} catch (error) {
console.error('Google Autocomplete search error:', error);
setAddressSuggestions([]);
}
}, 300);
}, []);
const handleSuggestionPress = useCallback(async (item: any) => {
console.log("Autocomplete suggestion clicked:", item.description);
// NEW: Dismiss the keyboard immediately after a suggestion is selected
Keyboard.dismiss();
setSearchQuery(item.description);
setAddressSuggestions([]); // Hide suggestions after selection
try {
const url = `https://maps.googleapis.com/maps/api/place/details/json?place_id=${item.place_id}&fields=geometry,name&key=${GOOGLE_API_KEY}`;
const response = await fetch(url);
const data = await response.json();
if (data && data.result && data.result.geometry) {
const { lat, lng } = data.result.geometry.location;
const name = data.result.name || item.description;
onSelectPlace(lat, lng, name);
} else {
Alert.alert('Location Error', 'Could not get precise coordinates for selected place.');
}
} catch (error) {
console.error('Google Place Details error:', error);
Alert.alert('Network Error', 'Failed to fetch location details.');
}
}, [onSelectPlace]);
const handleEnterPress = useCallback(() => {
if (addressSuggestions.length > 0) {
handleSuggestionPress(addressSuggestions[0]); // Take the first suggestion
}
}, [addressSuggestions, handleSuggestionPress]);
return (
<View style={styles.searchBarContainer}>
<TextInput
style={styles.searchBar}
placeholder="Search for a location (e.g., Oxford Street)"
placeholderTextColor="black" // Force dark placeholder for all platforms
value={searchQuery}
onChangeText={handleSearch}
onBlur={() => {
setTimeout(() => {
if (!isPressingSuggestion.current) {
setAddressSuggestions([]);
}
}, 100); // Small delay
}}
onSubmitEditing={handleEnterPress}
/>
{addressSuggestions.length > 0 && (
<FlatList
style={styles.suggestionsList}
data={addressSuggestions}
keyExtractor={(item) => item.place_id}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => (
<TouchableOpacity
style={styles.suggestionItem}
onPressIn={() => { isPressingSuggestion.current = true; }}
onPressOut={() => { isPressingSuggestion.current = false; }}
onPress={() => handleSuggestionPress(item)}
>
<Text style={styles.suggestionText}>{item.description}</Text>
</TouchableOpacity>
)}
initialNumToRender={5}
maxToRenderPerBatch={5}
windowSize={10}
/>
)}
</View>
);
};
const styles = StyleSheet.create({
searchBarContainer: {
position: 'absolute',
top: 20,
left: 10,
right: 10,
zIndex: 10,
backgroundColor: 'white',
borderRadius: 8,
padding: 10,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
searchBar: {
height: 40,
borderColor: '#ccc',
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 10,
fontSize: 16,
color: '#222', // Ensure dark text for visibility
},
suggestionsList: {
maxHeight: 200,
marginTop: 5,
borderRadius: 8,
overflow: 'hidden',
backgroundColor: 'white',
borderWidth: 1,
borderColor: '#eee',
},
suggestionItem: {
padding: 10,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
suggestionText: {
fontSize: 16,
color: '#222', // Ensure dark text for visibility
},
});
export default SearchComponent;

56
DRP-App/app/_layout.tsx Normal file
View File

@ -0,0 +1,56 @@
import { AuthProvider } from '@/context/AuthContext';
import { Slot, SplashScreen, useRouter } from 'expo-router';
import * as Notifications from 'expo-notifications';
import { useEffect, useState } from 'react';
import { Platform } from 'react-native';
import React from 'react';
// Prevent the splash screen from auto-hiding
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const router = useRouter();
const [appReady, setAppReady] = useState(false);
const [splashComplete, setSplashComplete] = useState(false);
useEffect(() => {
// Initialize your app
const prepare = async () => {
try {
// Request notification permissions
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') {
console.warn('Permission for notifications not granted!');
}
// Set up notification handler
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
// Artificially delay for demonstration
await new Promise(resolve => setTimeout(resolve, 2000));
} catch (e) {
console.warn(e);
} finally {
setAppReady(true);
await SplashScreen.hideAsync();
}
};
prepare();
}, []);
return (
<AuthProvider>
<Slot />
</AuthProvider>
);
}

514
DRP-App/app/add_mosque.tsx Normal file
View File

@ -0,0 +1,514 @@
// app/add_mosque.tsx
import React, { useState, useEffect, useRef } from 'react'; // useRef added for debounceTimeout
import {
View,
Text,
TextInput,
Button,
StyleSheet,
ScrollView,
Alert,
ActivityIndicator,
TouchableOpacity,
Platform, // Ensure Platform is imported for style usage
Switch,
FlatList, // Added FlatList for autocomplete suggestions
} from 'react-native';
import * as Location from 'expo-location';
import { Picker } from '@react-native-picker/picker';
import { useRouter, useLocalSearchParams } from 'expo-router';
interface AddPlaceResponse {
message: string;
placeId?: string;
}
const BACKEND_URL = 'http://132.145.65.145:8080'; // Your backend URL
const GOOGLE_API_KEY = 'AIzaSyB1WZHDqjGk696AmVw7tA2sMAuOurt552Q';
export default function AddMosqueScreen() {
const router = useRouter();
const params = useLocalSearchParams();
const lat = params.lat;
const lng = params.lng;
const addressParam = params.address;
const nameParam = params.name;
const [name, setName] = useState('');
const [address, setAddress] = useState('');
const [latitude, setLatitude] = useState<string>('');
const [longitude, setLongitude] = useState<string>('');
const [locationType, setLocationType] = useState<'mosque' | 'other' | 'outdoor_space' | 'multi_faith_room'>('mosque');
const [notes, setNotes] = useState('');
const [website, setWebsite] = useState(''); // NEW STATE FOR WEBSITE
const [isLoadingLocation, setIsLoadingLocation] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isGeocoding, setIsGeocoding] = useState(false); // Used for Google Details API fetch state
const [coordsObtained, setCoordsObtained] = useState(false);
// Autocomplete specific states
const [addressSuggestions, setAddressSuggestions] = useState<any[]>([]); // Google predictions have { description, place_id }
const [isAddressLoading, setIsAddressLoading] = useState(false); // Indicates loading for autocomplete suggestions
const debounceTimeout = useRef<NodeJS.Timeout | null>(null); // For debouncing input
// Track if user has interacted with the switches
const [womensSpace, setWomensSpace] = useState<null | boolean>(null);
const [wudu, setWudu] = useState<null | boolean>(null);
// Effect to auto-populate fields from query params on initial load
useEffect(() => {
if (lat && typeof lat === 'string') {
setLatitude(lat);
}
if (lng && typeof lng === 'string') {
setLongitude(lng);
}
if (addressParam && typeof addressParam === 'string') {
setAddress(decodeURIComponent(addressParam));
}
if (nameParam && typeof nameParam === 'string') {
setName(decodeURIComponent(nameParam));
}
if (lat && lng) {
setCoordsObtained(true);
}
}, [lat, lng, addressParam, nameParam]);
const getCurrentLocation = async () => {
setIsLoadingLocation(true);
setCoordsObtained(false); // Reset status
setLatitude(''); // Clear old coords
setLongitude(''); // Clear old coords
setAddress(''); // Clear address for new reverse geocoding
setAddressSuggestions([]); // Clear suggestions
let { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission denied', 'Permission to access location was denied. Cannot auto-fill coordinates.');
setIsLoadingLocation(false);
return;
}
try {
let location = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.High });
setLatitude(location.coords.latitude.toString());
setLongitude(location.coords.longitude.toString());
setCoordsObtained(true);
const reverseGeocode = await Location.reverseGeocodeAsync({
latitude: location.coords.latitude,
longitude: location.coords.longitude,
});
if (reverseGeocode && reverseGeocode.length > 0) {
const firstResult = reverseGeocode[0];
const formattedAddress = [
firstResult.name,
firstResult.street,
firstResult.city,
firstResult.postalCode,
firstResult.country,
].filter(Boolean).join(', ');
setAddress(formattedAddress);
}
} catch (error) {
console.error("Error getting current location:", error);
Alert.alert('Error', 'Failed to get current location. Please enter manually.');
} finally {
setIsLoadingLocation(false);
}
};
const fetchGoogleSuggestions = async (input: string) => {
if (!input || input.length < 3) {
setAddressSuggestions([]);
return;
}
// Debounce is now handled by handleAddressChangeWithDebounce,
// so this function itself doesn't need to debounce.
setIsAddressLoading(true); // Indicate loading for suggestions
try {
const url = `https://maps.googleapis.com/maps/api/place/autocomplete/json?input=${encodeURIComponent(input)}&key=${GOOGLE_API_KEY}&components=country:gb&location=51.5074,-0.1278&radius=20000`;
const response = await fetch(url);
const data = await response.json();
if (data && data.predictions) {
setAddressSuggestions(data.predictions);
} else {
setAddressSuggestions([]);
}
} catch (e) {
console.error('Google Autocomplete fetch error:', e);
setAddressSuggestions([]);
} finally {
setIsAddressLoading(false); // Stop loading for suggestions
}
};
const fetchPlaceDetails = async (placeId: string, description: string) => {
setIsGeocoding(true); // Indicates loading for place details (lat/lng)
setCoordsObtained(false); // Reset status while fetching details
setLatitude('');
setLongitude('');
setAddressSuggestions([]); // Hide suggestions list
try {
const url = `https://maps.googleapis.com/maps/api/place/details/json?place_id=${placeId}&fields=geometry&key=${GOOGLE_API_KEY}`;
const response = await fetch(url);
const data = await response.json();
if (data && data.result && data.result.geometry) {
const { lat, lng } = data.result.geometry.location;
setAddress(description);
setLatitude(lat.toString());
setLongitude(lng.toString());
setCoordsObtained(true);
Alert.alert('Location Found', `Coordinates for "${description}" obtained.`);
} else {
Alert.alert('Error', 'Could not retrieve coordinates for the selected place.');
}
} catch (e) {
console.error('Google Place Details fetch error:', e);
Alert.alert('Error', 'Failed to get location details. Please try again.');
} finally {
setIsGeocoding(false); // Stop loading for place details
}
};
// THIS IS THE DEBOUNCED HANDLER FOR THE ADDRESS TEXTINPUT
const handleAddressChangeWithDebounce = (text: string) => {
setAddress(text); // Update address state immediately
if (debounceTimeout.current) {
clearTimeout(debounceTimeout.current);
}
debounceTimeout.current = setTimeout(() => {
fetchGoogleSuggestions(text); // Call Google Autocomplete after debounce
}, 500); // Debounce for 500ms
};
const handleSuggestionPress = (item: any) => { // 'item' is a Google prediction object
fetchPlaceDetails(item.place_id, item.description);
};
const handleSubmit = async () => {
// Ensure essential fields are filled and coordinates are obtained
if (!name || !address || !latitude || !longitude || !locationType || womensSpace === null || wudu === null || !coordsObtained) {
Alert.alert('Missing Info', 'Please fill in all required fields (Name, Address, Location Type, Women\'s Space, Wudu Facilities) and ensure coordinates are obtained (use current location or select from address suggestions).');
return;
}
setIsSubmitting(true);
const payload = {
name,
address,
latitude: parseFloat(latitude),
longitude: parseFloat(longitude),
location_type: locationType,
womens_space: womensSpace,
wudu: wudu,
notes: notes,
website_url: website, // Include website in payload
};
try {
console.log("Sending new place payload:", payload);
const response = await fetch(`${BACKEND_URL}/places/new`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Unknown error' }));
console.error("Backend error response:", errorData);
throw new Error(errorData.message || `HTTP error! Status: ${response.status}`);
}
const result: AddPlaceResponse = await response.json();
Alert.alert('Success', result.message || 'Prayer Space added successfully!');
setName('');
setAddress('');
setLatitude('');
setLongitude('');
setLocationType('mosque');
setWomensSpace(null);
setWudu(null);
setNotes('');
setWebsite(''); // Clear website field
setCoordsObtained(false);
router.back();
} catch (error: any) {
console.error('Add mosque error:', error);
Alert.alert('Error', `Failed to add prayer space: ${error.message || 'Network error'}`);
} finally {
setIsSubmitting(false);
}
};
// Styles for Yes/No buttons for switches
const getSwitchButtonStyle = (selected: boolean | null, isYes: boolean) => ({
marginRight: 10,
borderWidth: 1,
borderColor: selected === isYes ? '#007bff' : '#ccc',
backgroundColor: selected === isYes ? '#e6f0ff' : '#fff',
borderRadius: 6,
paddingHorizontal: 16,
paddingVertical: 8,
marginLeft: 0,
});
const getSwitchButtonTextStyle = (selected: boolean | null, isYes: boolean) => ({
color: selected === isYes ? '#007bff' : '#333',
fontWeight: selected === isYes ? 'bold' : 'normal',
});
return (
<ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
<Text style={styles.title}>Add New Prayer Space</Text>
<Text style={styles.label}>Location Name *</Text>
<TextInput
style={styles.input}
placeholder="e.g., London Central Mosque"
value={name}
onChangeText={setName}
/>
<Text style={styles.label}>Address *</Text>
<TextInput
style={styles.input}
placeholder="Start typing an address or use current location"
value={address}
onChangeText={handleAddressChangeWithDebounce} // Correctly using debounced handler
onFocus={() => { if (address.length >=3 && addressSuggestions.length > 0) setAddressSuggestions(addressSuggestions); else if (address.length >=3) fetchGoogleSuggestions(address); }}
onBlur={() => setTimeout(() => setAddressSuggestions([]), 200)}
autoCorrect={false}
autoCapitalize="none"
/>
{isAddressLoading && <ActivityIndicator size="small" color="#007bff" style={{ marginBottom: 10 }} />}
{addressSuggestions.length > 0 && (
<ScrollView style={styles.suggestionsContainer} nestedScrollEnabled={true}>
{addressSuggestions.map((item) => (
<TouchableOpacity
key={item.place_id}
style={styles.suggestionItem}
onPress={() => fetchPlaceDetails(item.place_id, item.description)}
>
<Text>{item.description}</Text>
</TouchableOpacity>
))}
</ScrollView>
)}
<View style={styles.buttonRow}>
<Button
title={isLoadingLocation ? "Getting Location..." : "Use Current Location"}
onPress={getCurrentLocation}
disabled={isLoadingLocation || isGeocoding}
/>
</View>
{/* Display status of coordinates */}
{coordsObtained && latitude && longitude && (
<Text style={styles.coordsStatusText}>
Coordinates obtained: Lat {parseFloat(latitude).toFixed(4)}, Lng {parseFloat(longitude).toFixed(4)}
</Text>
)}
<Text style={styles.label}>Prayer Space Type *</Text>
<View style={styles.pickerContainer}>
<Picker
selectedValue={locationType}
// UPDATED: locationType values
onValueChange={(itemValue: 'mosque' | 'other' | 'outdoor_space' | 'multi_faith_room') => setLocationType(itemValue)}
style={styles.picker}
>
<Picker.Item label="Mosque" value="mosque" />
<Picker.Item label="Outdoor Space" value="outdoor_space" />
<Picker.Item label="Multi-Faith Room" value="multi_faith_room" />
<Picker.Item label="Other Prayer Space" value="other" />
</Picker>
</View>
<View style={styles.switchRow}>
<Text style={styles.label}>Women's Space Available *</Text>
<View style={{ flexDirection: 'row' }}>
<TouchableOpacity
style={getSwitchButtonStyle(womensSpace, false)}
onPress={() => setWomensSpace(false)}
>
<Text style={getSwitchButtonTextStyle(womensSpace, false)}>No</Text>
</TouchableOpacity>
<TouchableOpacity
style={getSwitchButtonStyle(womensSpace, true)}
onPress={() => setWomensSpace(true)}
>
<Text style={getSwitchButtonTextStyle(womensSpace, true)}>Yes</Text>
</TouchableOpacity>
</View>
</View>
<View style={styles.switchRow}>
<Text style={styles.label}>Wudu Facilities Available *</Text>
<View style={{ flexDirection: 'row' }}>
<TouchableOpacity
style={getSwitchButtonStyle(wudu, false)}
onPress={() => setWudu(false)}
>
<Text style={getSwitchButtonTextStyle(wudu, false)}>No</Text>
</TouchableOpacity>
<TouchableOpacity
style={getSwitchButtonStyle(wudu, true)}
onPress={() => setWudu(true)}
>
<Text style={getSwitchButtonTextStyle(wudu, true)}>Yes</Text>
</TouchableOpacity>
</View>
</View>
<Text style={styles.label}>Website URL (Optional)</Text>
<TextInput
style={styles.input}
placeholder="e.g., https://example.com"
value={website}
onChangeText={setWebsite}
keyboardType="url"
autoCapitalize="none"
/>
<Text style={styles.label}>Notes</Text>
<TextInput
style={[styles.input, styles.notesInput]}
placeholder="Any additional notes (e.g., opening hours, specific features)"
value={notes}
onChangeText={setNotes}
multiline
numberOfLines={4}
textAlignVertical="top"
/>
<Button
title={isSubmitting ? "Adding Prayer Space..." : "Add Prayer Space"}
onPress={handleSubmit}
disabled={isSubmitting || isLoadingLocation || isGeocoding || !coordsObtained || womensSpace === null || wudu === null}
/>
<TouchableOpacity style={styles.cancelButton} onPress={() => router.back()}>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8f9fa',
},
contentContainer: {
padding: 20,
paddingBottom: 40,
},
title: {
fontSize: 26,
fontWeight: 'bold',
marginBottom: 30,
textAlign: 'center',
color: '#333',
},
label: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
color: '#333',
},
input: {
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
paddingHorizontal: 15,
paddingVertical: 12,
fontSize: 16,
marginBottom: 15,
},
notesInput: {
height: 100,
paddingTop: 12,
},
buttonRow: {
flexDirection: 'row',
justifyContent: 'space-around',
marginBottom: 15,
},
pickerContainer: {
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
marginBottom: 15,
overflow: 'hidden',
},
picker: {
height: Platform.OS === 'ios' ? 120 : 50,
width: '100%',
},
switchRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 15,
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
paddingHorizontal: 15,
paddingVertical: 12,
},
coordsStatusText: {
fontSize: 14,
color: '#007bff',
textAlign: 'center',
marginBottom: 15,
},
geocodingIndicator: {
marginTop: -5,
marginBottom: 10,
},
cancelButton: {
marginTop: 15,
paddingVertical: 10,
alignItems: 'center',
},
cancelButtonText: {
color: '#dc3545',
fontSize: 16,
fontWeight: '600',
},
suggestionsContainer: {
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 6,
marginBottom: 15,
maxHeight: 150,
overflow: 'hidden',
},
suggestionItem: {
paddingVertical: 10,
paddingHorizontal: 12,
borderBottomColor: '#eee',
borderBottomWidth: 1,
width: '100%',
},
suggestionText: {
fontSize: 16,
color: '#333',
},
});

313
DRP-App/app/add_review.tsx Normal file
View File

@ -0,0 +1,313 @@
// app/add_review.tsx
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
Button,
StyleSheet,
ScrollView,
Alert,
TouchableOpacity,
} from 'react-native';
import { Picker } from '@react-native-picker/picker'; // If you were using a picker
import { useRouter, useLocalSearchParams } from 'expo-router'; // Import useRouter and useLocalSearchParams
// Ensure interfaces match your PrayerSpacesListPage.tsx and backend expectations
interface ReviewPayload {
user: string;
place: string; // Backend expects place NAME string for lookup
quiet: number | null;
clean: number | null;
private: number | null;
cleanWudu: number | null;
childFriendly: number | null;
safe: number | null;
notes?: string; // Corresponds to comment in your frontend if added
}
interface AddReviewResponse {
message: string;
reviewId?: string;
}
const BACKEND_URL = 'http://132.145.65.145:8080'; // Your backend URL
const HARDCODED_USERNAME = 'testuser'; // Hardcoded username string, matching your seed user
// --- Emoji Rating Helpers (copied from PrayerSpacesListPage.tsx) ---
// This ensures the new page has these functions available
function averageRatingToEmoji(avg: number) {
if (avg < 1.66) {
return "😞";
} else if (avg < 2.33) {
return "😐";
} else {
return "😊";
}
}
interface EmojiRatingSelectorProps {
label: string;
value: number | null;
onSelect: (value: number) => void;
style?: object;
}
const EmojiRatingSelector: React.FC<EmojiRatingSelectorProps> = ({ label, value, onSelect, style }) => {
const emojis = [
{ emoji: "😞", value: 1, label: "Poor" },
{ emoji: "😐", value: 2, label: "Okay" },
{ emoji: "😊", value: 3, label: "Great" }
];
return (
<View style={[addReviewStyles.ratingContainer, style]}>
<Text style={addReviewStyles.ratingLabel}>{label}</Text>
<View style={addReviewStyles.emojiRow}>
{emojis.map((item) => (
<TouchableOpacity
key={item.value}
style={[
addReviewStyles.emojiButton,
value === item.value && addReviewStyles.selectedEmoji
]}
onPress={() => onSelect(item.value)}
>
<Text style={addReviewStyles.emojiText}>{item.emoji}</Text>
<Text style={addReviewStyles.emojiLabel}>{item.label}</Text>
</TouchableOpacity>
))}
</View>
</View>
);
};
// --- End Emoji Rating Helpers ---
export default function AddReviewScreen() {
const router = useRouter();
const params = useLocalSearchParams(); // Get params passed from navigation
const placeName = typeof params.placeName === 'string' ? params.placeName : 'Unknown Place';
const placeId = typeof params.placeId === 'string' ? params.placeId : ''; // Place ID for potential future use or debugging
const [quiet, setQuiet] = useState<number | null>(null);
const [clean, setClean] = useState<number | null>(null);
const [privacy, setPrivacy] = useState<number | null>(null);
const [cleanWudu, setCleanWudu] = useState<number | null>(null);
const [childFriendly, setChildFriendly] = useState<number | null>(null);
const [safe, setSafe] = useState<number | null>(null);
const [notes, setNotes] = useState(''); // Changed from comment to notes to match backend model name
const isValidRating = (val: number | null): boolean => {
return val !== null && val >= 1 && val <= 3;
};
const handleSubmit = async () => {
// Validate all rating fields
if (!quiet || !clean || !privacy || !cleanWudu || !childFriendly || !safe) {
Alert.alert('Missing Info', 'Please fill in all rating fields.');
return;
}
if (![quiet, clean, privacy, cleanWudu, childFriendly, safe].every(isValidRating)) {
Alert.alert('Invalid Input', 'Please select a rating for each category (1-3).');
return;
}
// Prepare payload for backend - sending place NAME as string
const payload: ReviewPayload = {
user: HARDCODED_USERNAME,
place: placeName, // IMPORTANT: Sending place NAME, not ID, as per your backend's SaveReview
quiet: quiet,
clean: clean,
private: privacy,
cleanWudu: cleanWudu,
childFriendly: childFriendly,
safe: safe,
notes: notes, // Sending notes field
};
console.log("Sending review payload from AddReviewScreen:", payload);
try {
const response = await fetch(`${BACKEND_URL}/reviews/new`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Unknown error' }));
console.error("Backend error response:", errorData);
throw new Error(errorData.message || `HTTP error! Status: ${response.status}`);
}
const data: AddReviewResponse = await response.json();
Alert.alert('Success', data.message || 'Review submitted!');
// Clear form and navigate back
setQuiet(null);
setClean(null);
setPrivacy(null);
setCleanWudu(null);
setChildFriendly(null);
setSafe(null);
setNotes(''); // Clear notes field
router.back(); // Go back to the previous screen (PrayerSpacesListPage)
} catch (error: any) {
Alert.alert('Error', `Could not send review: ${error.message || error}`);
console.error("Review submission error:", error);
}
};
return (
<ScrollView style={addReviewStyles.container} contentContainerStyle={addReviewStyles.contentContainer}>
<Text style={addReviewStyles.title}>Submit a Review</Text>
<Text style={addReviewStyles.subtitle}>For: {placeName}</Text>
{/* Emoji Rating Selectors */}
<EmojiRatingSelector
label="Cleanliness Rating"
value={clean}
onSelect={setClean}
/>
<EmojiRatingSelector
label="Wudu Cleanliness Rating"
value={cleanWudu}
onSelect={setCleanWudu}
/>
<EmojiRatingSelector
label="Quietness Rating"
value={quiet}
onSelect={setQuiet}
/>
<EmojiRatingSelector
label="Privacy Rating"
value={privacy}
onSelect={setPrivacy}
/>
<EmojiRatingSelector
label="Child Friendliness Rating"
value={childFriendly}
onSelect={setChildFriendly}
/>
<EmojiRatingSelector
label="Safety Rating"
value={safe}
onSelect={setSafe}
/>
<Text style={addReviewStyles.label}>Your Comments (optional)</Text>
<TextInput
style={[addReviewStyles.input, addReviewStyles.notesInput]}
placeholder="Add your thoughts about this place..."
value={notes}
onChangeText={setNotes}
multiline
numberOfLines={4}
textAlignVertical="top"
/>
<Button title="Submit Review" onPress={handleSubmit} />
<TouchableOpacity onPress={() => router.back()} style={addReviewStyles.cancelButton}>
<Text style={addReviewStyles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
</ScrollView>
);
}
const addReviewStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8f9fa',
},
contentContainer: {
padding: 20,
paddingBottom: 40,
},
title: {
fontSize: 26,
fontWeight: 'bold',
marginBottom: 10,
textAlign: 'center',
color: '#333',
},
subtitle: {
fontSize: 18,
marginBottom: 25,
textAlign: 'center',
color: '#555',
fontWeight: '600',
},
label: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
color: '#333',
marginTop: 10, // Add some top margin for spacing
},
input: {
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
paddingHorizontal: 15,
paddingVertical: 12,
fontSize: 16,
marginBottom: 15,
},
notesInput: {
height: 100,
paddingTop: 12,
},
// Styles for EmojiRatingSelector (copied from PrayerSpacesListPage)
ratingContainer: {
marginBottom: 15,
alignItems: 'center',
backgroundColor: '#fff', // Added background for better visibility
borderRadius: 8,
borderWidth: 1,
borderColor: '#ddd',
paddingVertical: 12,
},
ratingLabel: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
color: '#333',
},
emojiRow: {
flexDirection: 'row',
justifyContent: 'space-around',
width: '90%',
},
emojiButton: {
padding: 8, // Slightly less padding for touch area
borderRadius: 5,
alignItems: 'center',
},
selectedEmoji: {
backgroundColor: '#e3f2fd',
borderColor: '#007AFF',
borderWidth: 1,
},
emojiText: {
fontSize: 30,
},
emojiLabel: {
fontSize: 12,
color: '#666',
marginTop: 5,
},
cancelButton: {
marginTop: 15,
paddingVertical: 10,
alignItems: 'center',
},
cancelButtonText: {
color: '#dc3545',
fontSize: 16,
fontWeight: '600',
},
});

377
DRP-App/app/upload.tsx Normal file
View File

@ -0,0 +1,377 @@
import React, { useState, useEffect } from 'react'; // ADDED useEffect
import { // Ensure all necessary components are imported
View,
Text,
TouchableOpacity,
Image,
Alert,
StyleSheet,
TextInput,
ScrollView,
ActivityIndicator,
} from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams } from 'expo-router'; // ADDED: Import useLocalSearchParams
interface UploadResponse {
success: boolean;
message: string;
imageUrl?: string;
}
export default function UploadTab() {
// Get parameters from the route
const params = useLocalSearchParams();
// Ensure placeId from params is treated as a string, provide fallback if not present
const initialPlaceId = typeof params.placeId === 'string' ? params.placeId : '';
const initialPlaceName = typeof params.placeName === 'string' ? params.placeName : 'Selected Place'; // For display
const [selectedImage, setSelectedImage] = useState<string | null>(null);
// Initialize placeId state with the value from params
const [placeId, setPlaceId] = useState<string>(initialPlaceId);
const [placeNameDisplay, setPlaceNameDisplay] = useState<string>(initialPlaceName); // State for displaying name
const [notes, setNotes] = useState<string>('');
const [uploading, setUploading] = useState<boolean>(false);
const BACKEND_URL = 'http://132.145.65.145:8080';
// Use useEffect to update placeId and placeNameDisplay if params change
// This handles cases where the component might already be mounted and params are updated
useEffect(() => {
if (typeof params.placeId === 'string' && params.placeId !== placeId) {
setPlaceId(params.placeId);
}
if (typeof params.placeName === 'string' && params.placeName !== placeNameDisplay) {
setPlaceNameDisplay(params.placeName);
}
}, [params.placeId, params.placeName]); // Depend on params.placeId and placeName
const pickImage = async () => {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission Required', 'Sorry, we need camera roll permissions to upload images.');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images, // Use MediaTypeOptions.Images directly
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
allowsMultipleSelection: false,
});
if (!result.canceled && result.assets && result.assets.length > 0) {
setSelectedImage(result.assets[0].uri);
}
};
const takePhoto = async () => {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission Required', 'Sorry, we need camera permissions to take photos.');
return;
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
if (!result.canceled && result.assets && result.assets.length > 0) {
setSelectedImage(result.assets[0].uri);
}
};
const uploadImage = async () => {
if (!selectedImage) {
Alert.alert('Error', 'Please select an image first');
return;
}
if (!placeId) { // Ensure placeId is not empty after potential navigation
Alert.alert('Error', 'Place ID is missing. Please select a mosque first.');
return;
}
setUploading(true);
try {
console.log('Uploading for placeId:', placeId);
console.log('Notes:', notes);
const formData = new FormData();
const imageFile = {
uri: selectedImage,
type: 'image/jpeg',
name: 'image.jpg',
} as any;
formData.append('image', imageFile);
formData.append('place_id', placeId.trim()); // Ensure this matches backend expected field name
formData.append('Notes', notes.trim()); // Ensure this matches backend expected field name
console.log('Sending form data with keys:', Array.from(formData.keys()));
for (let [key, value] of formData.entries()) {
console.log(key, value);
}
const response = await fetch(`${BACKEND_URL}/images/upload`, {
method: 'POST',
body: formData,
headers: {
'Accept': 'application/json',
// DO NOT set 'Content-Type' for FormData, fetch sets it automatically with boundary
},
});
let result: UploadResponse;
const responseCopy = response.clone(); // Clone response to read body twice if needed
try {
result = await response.json();
} catch (error) {
const text = await responseCopy.text();
throw new Error(text || "Unknown error parsing JSON response");
}
if (response.ok && result.success) {
Alert.alert('Success', 'Image uploaded successfully!');
setSelectedImage(null);
setPlaceId(initialPlaceId); // Reset to initial placeId from params, or empty if not from params
setPlaceNameDisplay(initialPlaceName); // Reset display name
setNotes('');
} else {
Alert.alert('Upload Failed', result.message || 'Failed to upload image. Please check backend logs.');
}
} catch (error) {
console.error('Upload error:', error);
Alert.alert('Error', `Upload failed: ${error.message || 'Network error'}.`);
} finally {
setUploading(false);
}
};
const clearImage = () => {
setSelectedImage(null);
};
return (
<ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
<Text style={styles.title}>Upload Image</Text>
{/* Display Place Name (User friendly) */}
<Text style={styles.placeNameDisplay}>For: {placeNameDisplay || 'No place selected'}</Text>
{/* Image Selection Section */}
<View style={styles.imageSection}>
{selectedImage ? (
<View style={styles.imageContainer}>
<Image source={{ uri: selectedImage }} style={styles.selectedImage} />
<TouchableOpacity style={styles.clearButton} onPress={clearImage}>
<Ionicons name="close-circle" size={24} color="#ff4444" />
</TouchableOpacity>
</View>
) : (
<View style={styles.placeholderContainer}>
<Ionicons name="image-outline" size={64} color="#ccc" />
<Text style={styles.placeholderText}>No image selected</Text>
</View>
)}
</View>
{/* Image Selection Buttons */}
<View style={styles.buttonRow}>
<TouchableOpacity style={styles.actionButton} onPress={pickImage}>
<Ionicons name="images-outline" size={20} color="#007AFF" />
<Text style={styles.buttonText}>Gallery</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionButton} onPress={takePhoto}>
<Ionicons name="camera-outline" size={20} color="#007AFF" />
<Text style={styles.buttonText}>Camera</Text>
</TouchableOpacity>
</View>
{/* Form Section */}
<View style={styles.formSection}>
<Text style={styles.label}>Notes</Text>
<TextInput
style={[styles.input, styles.notesInput]}
value={notes}
onChangeText={setNotes}
placeholder="Add notes about this image (optional)"
placeholderTextColor="#999"
multiline
numberOfLines={3}
textAlignVertical="top"
/>
</View>
{/* Upload Button */}
<TouchableOpacity
style={[styles.uploadButton, (!selectedImage || uploading || !placeId) && styles.uploadButtonDisabled]} // Disable if placeId is missing
onPress={uploadImage}
disabled={!selectedImage || uploading || !placeId} // Disable if placeId is missing
>
{uploading ? (
<ActivityIndicator color="#fff" />
) : (
<>
<Ionicons name="cloud-upload-outline" size={20} color="#fff" />
<Text style={styles.uploadButtonText}>Upload Image</Text>
</>
)}
</TouchableOpacity>
{/* Instructions */}
<View style={styles.instructionsContainer}>
<Text style={styles.instructionsTitle}>Instructions:</Text>
<Text style={styles.instructionsText}>
1. Select an image from your gallery or take a photo{'\n'}
2. Add optional notes{'\n'}
3. Tap Upload
</Text>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8f9fa',
},
contentContainer: {
padding: 20,
},
title: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 20,
textAlign: 'center',
color: '#333',
},
placeNameDisplay: { // New style to display the place name
fontSize: 18,
fontWeight: 'bold',
marginBottom: 15,
textAlign: 'center',
color: '#555',
},
imageSection: {
marginBottom: 20,
},
imageContainer: {
position: 'relative',
alignItems: 'center',
},
selectedImage: {
width: 300,
height: 200,
borderRadius: 10,
resizeMode: 'cover',
},
clearButton: {
position: 'absolute',
top: 10,
right: 10,
backgroundColor: '#fff',
borderRadius: 12,
},
placeholderContainer: {
height: 200,
backgroundColor: '#f0f0f0',
borderRadius: 10,
borderWidth: 2,
borderColor: '#ddd',
borderStyle: 'dashed',
justifyContent: 'center',
alignItems: 'center',
},
placeholderText: {
marginTop: 10,
color: '#999',
fontSize: 16,
},
buttonRow: {
flexDirection: 'row',
justifyContent: 'space-around',
marginBottom: 30,
},
actionButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#e3f2fd',
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: '#007AFF',
},
buttonText: {
marginLeft: 8,
color: '#007AFF',
fontSize: 16,
fontWeight: '500',
},
formSection: {
marginBottom: 30,
},
label: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
color: '#333',
},
input: {
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
paddingHorizontal: 15,
paddingVertical: 12,
fontSize: 16,
marginBottom: 15,
},
readOnlyInput: { // NEW STYLE for read-only text input
backgroundColor: '#e9ecef', // Lighter background for read-only
color: '#6c757d', // Slightly greyed out text
},
notesInput: {
height: 80,
paddingTop: 12,
},
uploadButton: {
backgroundColor: '#28a745',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 15,
borderRadius: 8,
marginBottom: 30,
},
uploadButtonDisabled: {
backgroundColor: '#ccc',
},
uploadButtonText: {
color: '#fff',
fontSize: 18,
fontWeight: 'bold',
marginLeft: 8,
},
instructionsContainer: {
backgroundColor: '#fff',
padding: 15,
borderRadius: 8,
borderLeftWidth: 4,
borderLeftColor: '#007AFF',
},
instructionsTitle: {
fontSize: 16,
fontWeight: 'bold',
marginBottom: 8,
color: '#333',
},
instructionsText: {
fontSize: 14,
color: '#666',
lineHeight: 20,
},
});

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,45 @@
import { PropsWithChildren, useState } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useColorScheme() ?? 'light';
return (
<ThemedView>
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}>
<IconSymbol
name="chevron.right"
size={18}
weight="medium"
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
/>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
});

View File

@ -0,0 +1,24 @@
import { Href, Link } from 'expo-router';
import { openBrowserAsync } from 'expo-web-browser';
import { type ComponentProps } from 'react';
import { Platform } from 'react-native';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (Platform.OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault();
// Open the link in an in-app browser.
await openBrowserAsync(href);
}
}}
/>
);
}

View File

@ -0,0 +1,18 @@
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
import { PlatformPressable } from '@react-navigation/elements';
import * as Haptics from 'expo-haptics';
export function HapticTab(props: BottomTabBarButtonProps) {
return (
<PlatformPressable
{...props}
onPressIn={(ev) => {
if (process.env.EXPO_OS === 'ios') {
// Add a soft haptic feedback when pressing down on the tabs.
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
props.onPressIn?.(ev);
}}
/>
);
}

View File

@ -0,0 +1,40 @@
import { useEffect } from 'react';
import { StyleSheet } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withTiming,
} from 'react-native-reanimated';
import { ThemedText } from '@/components/ThemedText';
export function HelloWave() {
const rotationAnimation = useSharedValue(0);
useEffect(() => {
rotationAnimation.value = withRepeat(
withSequence(withTiming(25, { duration: 150 }), withTiming(0, { duration: 150 })),
4 // Run the animation 4 times
);
}, [rotationAnimation]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotationAnimation.value}deg` }],
}));
return (
<Animated.View style={animatedStyle}>
<ThemedText style={styles.text}>👋</ThemedText>
</Animated.View>
);
}
const styles = StyleSheet.create({
text: {
fontSize: 28,
lineHeight: 32,
marginTop: -6,
},
});

View File

@ -0,0 +1,82 @@
import type { PropsWithChildren, ReactElement } from 'react';
import { StyleSheet } from 'react-native';
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollViewOffset,
} from 'react-native-reanimated';
import { ThemedView } from '@/components/ThemedView';
import { useBottomTabOverflow } from '@/components/ui/TabBarBackground';
import { useColorScheme } from '@/hooks/useColorScheme';
const HEADER_HEIGHT = 250;
type Props = PropsWithChildren<{
headerImage: ReactElement;
headerBackgroundColor: { dark: string; light: string };
}>;
export default function ParallaxScrollView({
children,
headerImage,
headerBackgroundColor,
}: Props) {
const colorScheme = useColorScheme() ?? 'light';
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollViewOffset(scrollRef);
const bottom = useBottomTabOverflow();
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
),
},
{
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
},
],
};
});
return (
<ThemedView style={styles.container}>
<Animated.ScrollView
ref={scrollRef}
scrollEventThrottle={16}
scrollIndicatorInsets={{ bottom }}
contentContainerStyle={{ paddingBottom: bottom }}>
<Animated.View
style={[
styles.header,
{ backgroundColor: headerBackgroundColor[colorScheme] },
headerAnimatedStyle,
]}>
{headerImage}
</Animated.View>
<ThemedView style={styles.content}>{children}</ThemedView>
</Animated.ScrollView>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: HEADER_HEIGHT,
overflow: 'hidden',
},
content: {
flex: 1,
padding: 32,
gap: 16,
overflow: 'hidden',
},
});

View File

@ -0,0 +1,60 @@
import { StyleSheet, Text, type TextProps } from 'react-native';
import { useThemeColor } from '@/hooks/useThemeColor';
export type ThemedTextProps = TextProps & {
lightColor?: string;
darkColor?: string;
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
};
export function ThemedText({
style,
lightColor,
darkColor,
type = 'default',
...rest
}: ThemedTextProps) {
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return (
<Text
style={[
{ color },
type === 'default' ? styles.default : undefined,
type === 'title' ? styles.title : undefined,
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
type === 'subtitle' ? styles.subtitle : undefined,
type === 'link' ? styles.link : undefined,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: '600',
},
title: {
fontSize: 32,
fontWeight: 'bold',
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
lineHeight: 30,
fontSize: 16,
color: '#0a7ea4',
},
});

View File

@ -0,0 +1,14 @@
import { View, type ViewProps } from 'react-native';
import { useThemeColor } from '@/hooks/useThemeColor';
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
};
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
}

View File

@ -0,0 +1,32 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
import { StyleProp, ViewStyle } from 'react-native';
export function IconSymbol({
name,
size = 24,
color,
style,
weight = 'regular',
}: {
name: SymbolViewProps['name'];
size?: number;
color: string;
style?: StyleProp<ViewStyle>;
weight?: SymbolWeight;
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
);
}

View File

@ -0,0 +1,37 @@
// Fallback for using MaterialIcons on Android and web.
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { SymbolViewProps, SymbolWeight } from 'expo-symbols';
import { ComponentProps } from 'react';
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
const MAPPING = {
'house.fill': 'home',
'paperplane.fill': 'send',
'chevron.left.forwardslash.chevron.right': 'code',
'chevron.right': 'chevron-right',
'person.fill': 'person',
'map.fill': 'map',
'star.fill': 'star',
'clock.fill': 'access-time',
} as IconMapping;
/**
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
* This ensures a consistent look across platforms, and optimal resource usage.
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName;
size?: number;
color: string | OpaqueColorValue;
style?: StyleProp<TextStyle>;
weight?: SymbolWeight;
}) {
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
}

View File

@ -0,0 +1,19 @@
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { BlurView } from 'expo-blur';
import { StyleSheet } from 'react-native';
export default function BlurTabBarBackground() {
return (
<BlurView
// System chrome material automatically adapts to the system's theme
// and matches the native tab bar appearance on iOS.
tint="systemChromeMaterial"
intensity={100}
style={StyleSheet.absoluteFill}
/>
);
}
export function useBottomTabOverflow() {
return useBottomTabBarHeight();
}

View File

@ -0,0 +1,6 @@
// This is a shim for web and Android where the tab bar is generally opaque.
export default undefined;
export function useBottomTabOverflow() {
return 0;
}

View File

@ -0,0 +1,26 @@
/**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
const tintColorLight = '#0a7ea4';
const tintColorDark = '#fff';
export const Colors = {
light: {
text: '#11181C',
background: '#fff',
tint: tintColorLight,
icon: '#687076',
tabIconDefault: '#687076',
tabIconSelected: tintColorLight,
},
dark: {
text: '#ECEDEE',
background: '#151718',
tint: tintColorDark,
icon: '#9BA1A6',
tabIconDefault: '#9BA1A6',
tabIconSelected: tintColorDark,
},
};

View File

@ -0,0 +1,61 @@
import * as SecureStore from 'expo-secure-store';
import {
createContext,
ReactNode,
useContext,
useEffect,
useState,
} from 'react';
type User = {
username: string;
};
type AuthContextType = {
user: User | null;
login: (username: string) => Promise<void>;
logout: () => Promise<void>;
loading: boolean;
};
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
SecureStore.getItemAsync('user').then((storedUser) => {
if (storedUser) {
setUser(JSON.parse(storedUser));
}
setLoading(false);
});
}, []);
const login = async (username: string) => {
const userData = { username };
setUser(userData);
await SecureStore.setItemAsync('user', JSON.stringify(userData));
};
const logout = async () => {
setUser(null);
await SecureStore.deleteItemAsync('user');
};
return (
<AuthContext.Provider value={{ user, login, logout, loading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}

31
DRP-App/eas.json Normal file
View File

@ -0,0 +1,31 @@
{
"cli": {
"version": ">= 16.7.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"production": {
"autoIncrement": true,
"android": {
"buildType": "apk"
}
}
},
"submit": {
"production": {}
}
}

10
DRP-App/eslint.config.js Normal file
View File

@ -0,0 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
module.exports = defineConfig([
expoConfig,
{
ignores: ['dist/*'],
},
]);

View File

@ -0,0 +1 @@
export { useColorScheme } from 'react-native';

View File

@ -0,0 +1,21 @@
import { useEffect, useState } from 'react';
import { useColorScheme as useRNColorScheme } from 'react-native';
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
const colorScheme = useRNColorScheme();
if (hasHydrated) {
return colorScheme;
}
return 'light';
}

View File

@ -0,0 +1,21 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}

62
DRP-App/package.json Normal file
View File

@ -0,0 +1,62 @@
{
"name": "prayer-space-finder",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint"
},
"dependencies": {
"@expo/vector-icons": "^14.1.0",
"@react-native-community/slider": "4.5.6",
"@react-native-picker/picker": "^2.11.0",
"@react-navigation/bottom-tabs": "^7.3.10",
"@react-navigation/elements": "^2.3.8",
"@react-navigation/native": "^7.1.6",
"@react-navigation/native-stack": "^7.3.10",
"better-sqlite3": "^11.10.0",
"dayjs": "^1.11.13",
"expo": "53.0.10",
"expo-blur": "~14.1.5",
"expo-constants": "~17.1.6",
"expo-dev-client": "~5.2.0",
"expo-font": "~13.3.1",
"expo-haptics": "~14.1.4",
"expo-image": "~2.2.0",
"expo-linking": "~7.1.5",
"expo-location": "^18.1.5",
"expo-router": "^5.0.7",
"expo-secure-store": "~14.2.3",
"expo-splash-screen": "~0.30.9",
"expo-status-bar": "~2.2.3",
"expo-symbols": "~0.4.5",
"expo-system-ui": "~5.0.8",
"expo-web-browser": "~14.1.6",
"notifications": "^0.2.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-native": "^0.79.3",
"react-native-gesture-handler": "~2.24.0",
"react-native-maps": "1.20.1",
"react-native-reanimated": "~3.17.4",
"react-native-safe-area-context": "^5.4.0",
"react-native-screens": "~4.11.1",
"react-native-web": "~0.20.0",
"react-native-webview": "13.13.5",
"expo-notifications": "~0.31.3",
"expo-image-picker": "~16.1.4",
"expo-build-properties": "~0.14.6"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/react": "~19.0.10",
"eslint": "^9.25.0",
"eslint-config-expo": "~9.2.0",
"typescript": "~5.8.3"
},
"private": true
}

117
DRP-App/prayerSpaces.json Normal file
View File

@ -0,0 +1,117 @@
[
{
"id": "ps001",
"name": "East London Mosque",
"latitude": 51.5152,
"longitude": -0.0604,
"address": "82-92 Whitechapel Rd, London E1 1JQ",
"type": "Mosque",
"facilities": {
"womensSpace": true,
"wudu": true
},
"openingHours": "Fajr to Isha. Women's section open for all prayers.",
"rating": 4.7,
"numberOfReviews": 1250,
"images": [
{
"url": "https://upload.wikimedia.org/wikipedia/commons/c/cc/East_London_Mosque_Front_View.jpg",
"note": "Front view of the East London Mosque and London Muslim Centre."
},
{
"url": "https://prayersconnect.com/api/image-resize/eyJlZGl0cyI6eyJyZXNpemUiOnsid2lkdGgiOjYxNiwiaGVpZ2h0IjozOTJ9fSwidXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL3BsYWNlcy9BQWNYcjhyb3haOS1rZ3FBbHluQTRjT2ZzcGlhLVlhZGRjX3pRV2dSNW1FQ2xKVFpHcEd4U21VLVVMWUFuUEVid3FGdnNNRGhUd0k1Wmc1NS1Kd1BzSW9vSDZuVm1hS0d5Z1JkSS1jPXMxNjAwLXc0MDMyIn0=",
"note": "Interior prayer hall, view towards the Mihrab."
},
{
"url": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRhfHiXh7Jl7iHjMy6PF7dn5GgTxHebNFVAAQ&s",
"note": "Women's gallery providing a separate prayer space."
},
{
"url": "https://www.eastlondonmosque.org.uk/Handlers/GetImage.ashx?IDMF=7c71af52-2713-4120-bd19-8b03a9b4d313&h=842&src=mc&w=1192",
"note": "Women's gallery providing a separate prayer space."
}
],
"notes": "Large, well-established mosque with extensive facilities. Can be busy for Jummah."
},
{
"id": "ps002",
"name": "London Central Mosque (Regent's Park Mosque)",
"latitude": 51.5269,
"longitude": -0.1601,
"address": "146 Park Rd, London NW8 7RG",
"type": "Mosque",
"facilities": {
"womensSpace": true,
"wudu": true
},
"openingHours": "Open for all 5 daily prayers. Check website for specific women's hall times during events.",
"rating": 4.6,
"numberOfReviews": 980,
"images": [
{
"url": "https://5pillarsuk.com/wp-content/uploads/2020/01/27895109593_c7f7aeac6c_b.jpg",
"note": "Front view of Regents Park Mosque."
},
{
"url": "https://d3sux4fmh2nu8u.cloudfront.net/Pictures/480xany/2/5/9/1849259_londonmosquepa35501250_912549.jpg",
"note": "Interior prayer hall, view towards the Mihrab."
},
{
"url": "https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjuoNbNEhP7yg4OiYq6TGEGQ8nFosHWWzrkIeg2LLSftg_D3mjvsoEozN3BIp-K8SPDtnTf-XCskDYpLLpsUnKyett-8fkQ4gp5BC4d7CoCDs0SwP01ou-ogk2zFCPwxTqYIxqg0wWK7Rs/s1600/IMG_3047.JPG",
"note": "Women's gallery providing a separate prayer space."
}
],
"notes": "Iconic mosque, landmark building. Women's gallery may have restricted access during large events."
},
{
"id": "ps003",
"name": "Finsbury Park Mosque",
"latitude": 51.5614,
"longitude": -0.1081,
"address": "7-11 St Thomas's Rd, London N4 2QH",
"type": "Mosque",
"facilities": {
"womensSpace": true,
"wudu": true
},
"openingHours": "Open for all prayers.",
"rating": 4.3,
"numberOfReviews": 350,
"images": [],
"notes": "Community-focused mosque."
},
{
"id": "ps004",
"name": "Mayfair Islamic Centre",
"latitude": 51.5100,
"longitude": -0.1475,
"address": "19 Hertford St, London W1J 7RU",
"type": "Prayer Room",
"facilities": {
"womensSpace": true,
"wudu": true
},
"openingHours": "Typically open Dhuhr, Asr, Maghrib. Check posted times.",
"rating": 4.0,
"numberOfReviews": 45,
"images": [],
"notes": "Smaller prayer room, can be difficult to find. Limited space."
},
{
"id": "ps005",
"name": "Selfridges Quiet Room (Men & Women)",
"latitude": 51.5141,
"longitude": -0.1508,
"address": "400 Oxford St, London W1A 1AB (4th Floor)",
"type": "Community Space",
"facilities": {
"womensSpace": true,
"wudu": false
},
"openingHours": "During store opening hours. Ask staff for access.",
"rating": 3.8,
"numberOfReviews": 22,
"images": [],
"notes": "Multi-faith quiet room. No dedicated wudu facilities within the room. Not exclusively for Muslims."
}
]

View File

@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const root = process.cwd();
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
const exampleDir = "app-example";
const newAppDir = "app";
const exampleDirPath = path.join(root, exampleDir);
const indexContent = `import { Text, View } from "react-native";
export default function Index() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Text>Edit app/index.tsx to edit this screen.</Text>
</View>
);
}
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
`;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const moveDirectories = async (userInput) => {
try {
if (userInput === "y") {
// Create the app-example directory
await fs.promises.mkdir(exampleDirPath, { recursive: true });
console.log(`📁 /${exampleDir} directory created.`);
}
// Move old directories to new app-example directory or delete them
for (const dir of oldDirs) {
const oldDirPath = path.join(root, dir);
if (fs.existsSync(oldDirPath)) {
if (userInput === "y") {
const newDirPath = path.join(root, exampleDir, dir);
await fs.promises.rename(oldDirPath, newDirPath);
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
} else {
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
console.log(`❌ /${dir} deleted.`);
}
} else {
console.log(`➡️ /${dir} does not exist, skipping.`);
}
}
// Create new /app directory
const newAppDirPath = path.join(root, newAppDir);
await fs.promises.mkdir(newAppDirPath, { recursive: true });
console.log("\n📁 New /app directory created.");
// Create index.tsx
const indexPath = path.join(newAppDirPath, "index.tsx");
await fs.promises.writeFile(indexPath, indexContent);
console.log("📄 app/index.tsx created.");
// Create _layout.tsx
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
await fs.promises.writeFile(layoutPath, layoutContent);
console.log("📄 app/_layout.tsx created.");
console.log("\n✅ Project reset complete. Next steps:");
console.log(
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
userInput === "y"
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
}
};
rl.question(
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
(answer) => {
const userInput = answer.trim().toLowerCase() || "y";
if (userInput === "y" || userInput === "n") {
moveDirectories(userInput).finally(() => rl.close());
} else {
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
);

18
DRP-App/tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"jsx": "react",
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}

21
LICENCE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) <YEAR> <COPYRIGHT HOLDER>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

62
README.md Normal file
View File

@ -0,0 +1,62 @@
# Sajidaat (سَاجِدَات) Prayer Space Finder
**Find your peace. Find your place.**
This project was developed as part of the _"Designing for Real People"_ module for the second-year Computing course at Imperial College London. The brief was to identify a real-world problem faced by a specific community and develop a human-centered digital solution.
---
## 📖 Table of Contents
- [❔ Problem](#-the-problem)
- [✅ Solution](#-the-solution)
- [✨ Core Features](#-core-features)
- [🛠️ Tech Stack](#-tech-stack)
- [🚀 Getting Started](#-getting-started)
---
## ❔ The Problem
For many Muslims, especially women, finding a suitable prayer space while away from home is a source of anxiety. Standard mapping tools lack crucial details like the availability of a women's section or its cleanliness, leading to wasted time and forcing individuals to pray in unsuitable public spaces.
---
## ✅ The Solution
**Sajidaat** is a community-driven mobile app that empowers Muslims to find and verify suitable prayer spaces with confidence. The name "Sajidaat" (سَاجِدَات) is Arabic for "women in prostration," signifying our focus on this challenge. By transforming private frustrations into public, actionable feedback, Sajidaat not only helps individuals but also encourages the development of more inclusive community spaces.
---
## ✨ Core Features
- 📍 **Interactive Map & Distance-Sorted List**
- ♀️ **Filtering:** For Womens Spaces, Wudu, Ratings, etc.
- ⭐ **Community-Driven Ratings & Reviews**
- 🖼️ **Image Gallery** with Notes for each location
- **Crowdsourced Spaces:** Users can add new locations
---
## 🛠️ Tech Stack
- **Framework:** React Native with Expo
- **Frontend Language:** TypeScript
- **Prototyping & Mobile Runtime:** Expo Go
- **Mapping:** `react-native-maps` (Google Maps)
- **Builds:** EAS Build
- **Backend Server:** Golang (Gin + GORM)
- **Database:** PostgreSQL
- **Environment & Deployment:** Docker for prototyping and portability
---
## 🚀 Getting Started
To run this project locally:
1. Clone the repository
2. Run `npm install` (or `yarn install`)
3. Create a `.env` file in the root directory with your API keys
5. Start the server with: npx expo start

32
backend/Dockerfile Normal file
View File

@ -0,0 +1,32 @@
# Use official Go image as build environment
FROM golang:1.24-alpine AS builder
# Set working directory inside the container
WORKDIR /app
# Copy go.mod and go.sum first (for caching dependencies)
COPY go.mod go.sum ./
# Download dependencies (cached if no changes)
RUN go mod download
# Copy the rest of the source code
COPY . .
# Build the Go app statically linked for Alpine
RUN CGO_ENABLED=0 GOOS=linux go build -o main
# Start a new minimal image to run the app
FROM alpine:latest
# Install ca-certificates for HTTPS calls (if needed)
RUN apk --no-cache add ca-certificates
# Copy the binary from builder
COPY --from=builder /app/main /main
# Expose port your app listens on
EXPOSE 8080
# Run the binary
CMD ["./main"]

View File

@ -0,0 +1,48 @@
package database
import (
"os"
"log"
"fmt"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
var DB *gorm.DB
func getConnectionString() string {
var (
host = os.Getenv("DB_HOST")
port = os.Getenv("DB_PORT")
user = os.Getenv("DB_USER")
password = os.Getenv("DB_PASSWORD")
dbname = os.Getenv("DB_NAME")
)
log.Printf("[LOG] Attempting to connect with: host=%s, port=%s, user=%s, dbname=%s",
host, port, user, dbname)
connStr := fmt.Sprintf(
"postgres://%s:%s@%s:%s/%s?sslmode=disable",
user,
password,
host,
port,
dbname,
)
return connStr
}
func ConnectDB() {
db_url := getConnectionString()
db, err := gorm.Open(postgres.Open(db_url), &gorm.Config{})
if err != nil {
log.Fatal("[FAIL] Failed to connect to database:", err)
}
fmt.Println("[SUCCESS] Connected to database successfully")
DB = db
}

50
backend/go.mod Normal file
View File

@ -0,0 +1,50 @@
module backend
go 1.23
toolchain go1.23.10
require (
github.com/gin-gonic/gin v1.10.1
github.com/google/uuid v1.6.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.30.0
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

117
backend/go.sum Normal file
View File

@ -0,0 +1,117 @@
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

17
backend/main.go Normal file
View File

@ -0,0 +1,17 @@
package main
import (
"backend/database"
"backend/routes"
"log"
)
func main() {
database.ConnectDB()
r := routes.SetupRoutes()
log.Println("[LOG]: backend server started")
r.Run()
}

12
backend/models/image.go Normal file
View File

@ -0,0 +1,12 @@
package models
import (
"github.com/google/uuid"
)
type PlaceImage struct {
ID uuid.UUID
PlaceID uuid.UUID
ImageURL string
Notes string
}

18
backend/models/place.go Normal file
View File

@ -0,0 +1,18 @@
package models
import (
"github.com/google/uuid"
)
type Place struct {
ID uuid.UUID
Name string
Latitude float64
Longitude float64
Address string
LocationType string
WomensSpace bool
Wudu bool
WebsiteURL string
Notes string
}

18
backend/models/review.go Normal file
View File

@ -0,0 +1,18 @@
package models
import (
"github.com/google/uuid"
)
type Review struct {
ID uuid.UUID
Reviewer uuid.UUID
Place uuid.UUID
Quiet int
Clean int
CleanWudu int
Private int
ChildFriendly int
Safe int
Notes string
}

11
backend/models/user.go Normal file
View File

@ -0,0 +1,11 @@
package models
import (
"github.com/google/uuid"
)
type User struct {
ID uuid.UUID
Username string
HashedPass string
}

52
backend/routes/routes.go Normal file
View File

@ -0,0 +1,52 @@
package routes
import (
"github.com/gin-gonic/gin"
"backend/server"
"os" // os is used by ensureUploadsDir
)
func SetupRoutes() *gin.Engine {
r := gin.Default()
// Create uploads directory if it doesn't exist
if err := ensureUploadsDir(); err != nil {
panic("Failed to create uploads directory: " + err.Error())
}
// Health
r.GET("/health", server.HealthCheck)
// Reviews
r.GET("/reviews/query", server.QueryReviews)
r.POST("/reviews/new", server.SaveReview)
// Places
r.GET("/places/query", server.QueryPlaces)
r.POST("/places/new", server.SavePlace)
r.PUT("/places/:id/website", server.UpdatePlaceWebsite) // NEW: Route for updating website
// Place Images
r.GET("/place_images/query", server.QueryImages)
r.GET("/place_images/query/:place_id", server.QueryImagesForPlace)
r.GET("/place_images/folder", server.ListEndpoints)
// New Image Upload Endpoint
r.POST("/images/upload", server.UploadImage)
// Serve static files from uploads directory
r.Static("/uploads", "./uploads")
return r
}
// ensureUploadsDir creates the uploads directory if it doesn't exist
func ensureUploadsDir() error {
uploadDir := "./uploads"
if _, err := os.Stat(uploadDir); os.IsNotExist(err) {
return os.Mkdir(uploadDir, 0755)
}
return nil
}

447
backend/server/server.go Normal file
View File

@ -0,0 +1,447 @@
package server
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"backend/database"
"backend/models"
"fmt"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
)
// FrontendReviewPayload: EXPECTS `user` AND `place` AS STRINGS
type FrontendReviewPayload struct {
User string `json:"user"`
Place string `json:"place"`
Quiet int `json:"quiet"`
Clean int `json:"clean"`
Private int `json:"private"`
CleanWudu int `json:"cleanWudu"`
ChildFriendly int `json:"childFriendly"`
Safe int `json:"safe"`
Notes string `json:"notes"`
}
// FrontendReview: Type used when marshalling reviews for the frontend (e.g., in QueryReviews)
type FrontendReview struct {
User string `json:"User"`
Place string `json:"Place"`
Quiet int `json:"Quiet"`
Clean int `json:"Clean"`
CleanWudu int `json:"CleanWudu"`
Private int `json:"Private"`
ChildFriendly int `json:"ChildFriendly"`
Safe int `json:"Safe"`
Notes string `json:"Notes"`
}
// FrontendPlacePayload: For adding new places - NOW INCLUDES WebsiteURL
type FrontendPlacePayload struct {
Name string `json:"name"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Address string `json:"address"`
LocationType string `json:"location_type"`
WomensSpace bool `json:"womens_space"`
Wudu bool `json:"wudu"`
Notes string `json:"notes"`
WebsiteURL string `json:"website_url"` // ADDED: Matches frontend's 'website' field in AddMosqueScreen
}
// FrontendPlace: Type used when marshalling places for the frontend (e.g., in QueryPlaces)
type FrontendPlace struct {
ID uuid.UUID `json:"ID"`
Name string `json:"Name"`
Latitude float64 `json:"Latitude"`
Longitude float64 `json:"Longitude"`
Address string `json:"Address"`
LocationType string `json:"LocationType"`
WomensSpace bool `json:"WomensSpace"`
Wudu bool `json:"Wudu"`
WebsiteURL string `json:"WebsiteURL"`
Notes string `json:"Notes"`
Images []models.PlaceImage `json:"Images"`
Reviews []models.Review `json:"Reviews"`
}
func HealthCheck(c *gin.Context) {
c.JSON(http.StatusOK, "status: healthy")
}
func QueryPlaces(c *gin.Context) {
var places []models.Place
result := database.DB.Find(&places)
if result.Error != nil {
log.Printf("[ERROR]: error while retrieving places from database: %s", result.Error)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to retrieve places: %s", result.Error)})
return
}
var newPlaces []FrontendPlace
for _, oldPlace := range places {
newPlace := toFrontendPlace(oldPlace)
newPlaces = append(newPlaces, newPlace)
}
c.JSON(http.StatusOK, newPlaces)
}
func QueryImages(c *gin.Context) {
var images []models.PlaceImage
result := database.DB.Find(&images)
if result.Error != nil {
log.Printf("[ERROR]: error while retrieving images from database: %s", result.Error)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to retrieve images: %s", result.Error)})
return
}
c.JSON(http.StatusOK, images)
}
func QueryImagesForPlace(c *gin.Context) {
placeID := c.Param("place_id")
var images []models.PlaceImage
result := database.DB.
Where("place_id = ?", placeID).
Find(&images)
if result.Error != nil {
log.Printf("[ERROR]: error while retrieving images for place from database: %s", result.Error)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to retrieve images for place: %s", result.Error)})
return
}
c.JSON(http.StatusOK, images)
}
func QueryReviews(c *gin.Context) {
var reviews []models.Review
database.DB.Find(&reviews)
frontendReviews := []FrontendReview{}
for _, oldReview := range reviews {
newReview := toFrontendReview(oldReview)
frontendReviews = append(frontendReviews, newReview)
log.Printf("%+v\n", newReview)
}
log.Printf("[LOG]: Fetched frontend reviews: %+v", frontendReviews)
c.JSON(http.StatusOK, frontendReviews)
}
func SaveReview(c *gin.Context) {
var payload FrontendReviewPayload
if err := c.BindJSON(&payload); err != nil {
log.Printf("[ERROR]: Failed to bind JSON for review: %s", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request payload"})
return
}
log.Printf("[LOG]: Received review payload: %+v", payload)
// Look up Place by Name
var place models.Place
database.DB.Where("name = ?", payload.Place).First(&place)
if place.ID == uuid.Nil {
log.Printf("[ERROR]: Place with name '%s' not found for review submission.", payload.Place)
c.JSON(http.StatusNotFound, gin.H{"error": "Place not found. Please ensure the place name is correct."})
return
}
// Look up Reviewer by Username
var reviewer models.User
database.DB.Where("username = ?", payload.User).First(&reviewer)
if reviewer.ID == uuid.Nil {
log.Printf("[ERROR]: Reviewer with username '%s' not found for review submission.", payload.User)
c.JSON(http.StatusNotFound, gin.H{"error": "Reviewer user not found. Please ensure username is correct."})
return
}
review := models.Review{
ID: uuid.New(),
Reviewer: reviewer.ID,
Place: place.ID,
Quiet: payload.Quiet,
Clean: payload.Clean,
Private: payload.Private,
CleanWudu: payload.CleanWudu,
ChildFriendly: payload.ChildFriendly,
Safe: payload.Safe,
Notes: payload.Notes,
}
result := database.DB.Create(&review)
if result.Error != nil {
log.Printf("[ERROR]: Error creating review in DB: %s", result.Error)
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"message": "Review submitted successfully!", "reviewId": review.ID.String()})
}
// SavePlace function (for adding new mosques)
func SavePlace(c *gin.Context) {
var payload FrontendPlacePayload
if err := c.BindJSON(&payload); err != nil {
log.Printf("[ERROR]: Failed to bind JSON for new place: %s", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request payload"})
return
}
log.Printf("[LOG]: Received new place payload: %+v", payload)
place := models.Place{
ID: uuid.New(),
Name: payload.Name,
Latitude: payload.Latitude,
Longitude: payload.Longitude,
Address: payload.Address,
LocationType: payload.LocationType,
WomensSpace: payload.WomensSpace,
Wudu: payload.Wudu,
Notes: payload.Notes,
WebsiteURL: payload.WebsiteURL, // Ensure models.Place has WebsiteURL field
}
result := database.DB.Create(&place)
if result.Error != nil {
log.Printf("[ERROR]: Error creating new place in DB: %s", result.Error)
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"message": "Place added successfully!", "placeId": place.ID.String()})
}
// UploadImage function (Fixed ImageURL storage)
func UploadImage(c *gin.Context) {
placeIDStr := c.PostForm("place_id")
notes := c.PostForm("Notes")
if placeIDStr == "" {
log.Printf("[ERROR]: Place ID is missing in image upload.")
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "Place ID is required for image upload."})
return
}
file, err := c.FormFile("image")
if err != nil {
log.Printf("[ERROR]: Failed to get image file from form: %s", err)
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": fmt.Sprintf("Failed to get image file: %s", err)})
return
}
if !isValidImage(file) {
log.Printf("[ERROR]: Invalid image file type for upload: %s", file.Header.Get("Content-Type"))
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "Invalid file type. Only JPEG, PNG, and GIF are allowed."})
return
}
placeUUID, err := uuid.Parse(placeIDStr)
if err != nil {
log.Printf("[ERROR]: Invalid Place ID UUID format for image upload '%s': %s", placeIDStr, err)
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "Invalid Place ID format."})
return
}
var existingPlace models.Place
if err := database.DB.First(&existingPlace, placeUUID).Error; err != nil {
log.Printf("[ERROR]: Place with ID %s not found for image upload: %s", placeUUID, err)
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "Place not found."})
return
}
// Generate a unique filename and path
ext := filepath.Ext(file.Filename)
uniqueFileName := fmt.Sprintf("%s-%s%s",
time.Now().Format("20060102-150405"),
uuid.New().String(),
ext)
// filePath is where the file is physically saved on the server
filePath := filepath.Join("uploads", uniqueFileName)
// dbImageURL is the path stored in the database, relative to the web server's static route
dbImageURL := fmt.Sprintf("/uploads/%s", uniqueFileName) // Correct format for web serving
if err := c.SaveUploadedFile(file, filePath); err != nil {
log.Printf("[ERROR]: Failed to save uploaded file '%s' to %s: %s", file.Filename, filePath, err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "Failed to save image file on server."})
return
}
// Store image metadata in the database (place_images table)
image := models.PlaceImage{
ID: uuid.New(),
PlaceID: placeUUID,
ImageURL: dbImageURL, // FIXED: Store the web-accessible relative URL here
Notes: notes, // Store notes from the form
}
result := database.DB.Create(&image)
if result.Error != nil {
log.Printf("[ERROR]: Error saving image metadata to database: %s", result.Error)
// Clean up the file if DB save fails
if removeErr := os.Remove(filePath); removeErr != nil {
log.Printf("[ERROR]: Failed to clean up uploaded file '%s' after DB error: %s", filePath, removeErr)
}
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": fmt.Sprintf("Failed to save image metadata: %s", result.Error)})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "File uploaded successfully",
"imageUrl": dbImageURL, // Return the relative path
"imageId": image.ID.String(),
"placeId": image.PlaceID.String(),
})
}
func UpdatePlaceWebsite(c *gin.Context) {
placeID := c.Param("id") // Get the place ID from the URL parameter
var payload struct { // Define an anonymous struct for the incoming JSON payload
WebsiteURL string `json:"website_url"` // Matches payload from frontend
}
if err := c.BindJSON(&payload); err != nil {
log.Printf("[ERROR]: Failed to bind JSON for website update: %s", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request payload"})
return
}
log.Printf("[LOG]: Received website update payload for Place ID %s: %+v", placeID, payload)
// Parse the placeID string to uuid.UUID
placeUUID, err := uuid.Parse(placeID)
if err != nil {
log.Printf("[ERROR]: Invalid Place ID UUID format for website update '%s': %s", placeID, err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Place ID format."})
return
}
// Find the place by ID and update its WebsiteURL
// Note: models.Place must have WebsiteURL field mapped to website_url in DB
result := database.DB.Model(&models.Place{}).Where("id = ?", placeUUID).Update("WebsiteURL", payload.WebsiteURL)
if result.Error != nil {
log.Printf("[ERROR]: Error updating website for place %s in DB: %s", placeID, result.Error)
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
if result.RowsAffected == 0 {
log.Printf("[WARN]: No place found with ID %s for website update.", placeID)
c.JSON(http.StatusNotFound, gin.H{"error": "Place not found."})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Website updated successfully!"})
}
// Helper function to validate image files
func isValidImage(file *multipart.FileHeader) bool {
allowedTypes := []string{"image/jpeg", "image/png", "image/gif"}
contentType := file.Header.Get("Content-Type")
for _, t := range allowedTypes {
if t == contentType {
return true
}
}
return false
}
// ListEndpoints function (from your provided routes.go, for debugging/listing files)
func ListEndpoints(c *gin.Context) {
dirPath := "./uploads"
entries, err := os.ReadDir(dirPath)
if err != nil {
log.Println("Error reading directory:", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to read uploads directory: %s", err)})
return
}
var fileNames []string
for _, entry := range entries {
if !entry.IsDir() {
fileNames = append(fileNames, entry.Name())
}
}
c.JSON(http.StatusOK, gin.H{"files": fileNames, "message": "List of files in uploads directory."})
}
func toFrontendReview(review models.Review) FrontendReview {
var user models.User
var place models.Place
if err := database.DB.First(&user, review.Reviewer).Error; err != nil {
log.Printf("[WARN]: User with ID %s not found for review %s: %s", review.Reviewer, review.ID, err)
user.Username = "Unknown User"
}
if err := database.DB.First(&place, review.Place).Error; err != nil {
log.Printf("[WARN]: Place with ID %s not found for review %s: %s", review.Place, review.ID, err)
place.Name = "Unknown Place"
}
newReview := FrontendReview{
User: user.Username,
Place: place.Name,
Quiet: review.Quiet,
Clean: review.Clean,
CleanWudu: review.CleanWudu,
Private: review.Private,
ChildFriendly: review.ChildFriendly,
Safe: review.Safe,
Notes: review.Notes,
}
return newReview
}
func toFrontendPlace(place models.Place) FrontendPlace {
var reviews []models.Review
var images []models.PlaceImage
database.DB.Where("place = ?", place.ID).Find(&reviews)
database.DB.Where("place_id = ?", place.ID).Find(&images)
newPlace := FrontendPlace{
ID: place.ID,
Name: place.Name,
Latitude: place.Latitude,
Longitude: place.Longitude,
Address: place.Address,
LocationType: place.LocationType,
WomensSpace: place.WomensSpace,
Wudu: place.Wudu,
WebsiteURL: place.WebsiteURL,
Notes: place.Notes,
Images: images,
Reviews: reviews,
}
return newPlace
}

View File

@ -0,0 +1,40 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE places (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255),
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
address VARCHAR(1023),
location_type VARCHAR(255) CHECK (location_type IN ('mosque', 'other', 'outdoor_space', 'multi_faith_room')),
womens_space BOOLEAN,
wudu BOOLEAN,
website_url VARCHAR(2047) DEFAULT NULL,
notes VARCHAR(4095)
);
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(255),
hashedpass VARCHAR(255)
);
CREATE TABLE reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reviewer UUID REFERENCES users(id),
place UUID REFERENCES places(id),
quiet INT NOT NULL,
clean INT NOT NULL,
clean_wudu INT NOT NULL,
private INT NOT NULL,
child_friendly INT NOT NULL,
safe INT NOT NULL,
notes VARCHAR(4095) DEFAULT NULL
);
CREATE TABLE place_images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
place_id UUID REFERENCES places(id),
image_url VARCHAR(2047),
notes VARCHAR(4095)
)

243
database/init/02-seed.sql Normal file
View File

@ -0,0 +1,243 @@
INSERT INTO users (id, username, hashedpass)
VALUES ('123e4567-e89b-12d3-a456-426614174000', 'testuser', 'password');
INSERT INTO places (id, name, longitude, latitude, address, location_type, womens_space, wudu, website_url, notes)
VALUES (
'f47ac10b-58cc-4372-a567-0e02b2c3d479',
'East London Mosque',
-0.0604,
51.5152,
'82-92 Whitechapel Rd, London E1 1JQ',
'mosque',
true,
true,
'https://www.eastlondonmosque.org.uk/',
'Large, well-established mosque with extensive facilities. Can be busy for Jummah.'
);
INSERT INTO places (id, name, longitude, latitude, address, location_type, womens_space, wudu, website_url, notes)
VALUES (
'c1d2b340-a9fb-4e39-9d58-8d537012fc1f',
'London Central Mosque',
-0.1618,
51.5316,
'146 Park Rd, London NW8 7RG',
'mosque',
true,
true,
'https://www.iccuk.org/',
'Iconic mosque near Regents Park. Very popular and spacious. Good facilities for women.'
);
INSERT INTO places (id, name, longitude, latitude, address, location_type, womens_space, wudu, website_url, notes)
VALUES (
'b8f6f9a2-8b1e-4c01-988f-30c66c3c4b7f',
'Finsbury Park Mosque',
-0.1094,
51.5651,
'711 St Thomass Rd, London N4 2QH',
'mosque',
true,
true,
'https://finsburyparkmosque.org/',
'Active community mosque. Offers classes and events. Womens section available upstairs.'
);
INSERT INTO places (id, name, longitude, latitude, address, location_type, womens_space, wudu, website_url, notes)
VALUES (
'e347c2be-338e-41d9-b9b0-dacc5a77fd22',
'Brick Lane Mosque',
-0.0713,
51.5207,
'1 Fournier St, London E1 6QE',
'mosque',
true,
true,
'https://bricklanejammemasjid.org.uk/',
'Housed in a historic building. Limited space but centrally located. Jummah can be crowded.'
);
INSERT INTO places (id, name, longitude, latitude, address, location_type, womens_space, wudu, website_url, notes)
VALUES (
'd47b6d77-9c47-4dd1-8361-b5bdf5f9630f',
'West London Islamic Centre',
-0.3295,
51.5111,
'Brownlow Rd, London W13 0SQ',
'mosque',
true,
true,
'https://wlic.co.uk/',
'Modern, active mosque with good facilities. Family-friendly. Separate prayer hall for women.'
);
INSERT INTO reviews (id, reviewer, place, quiet, clean, clean_wudu, private, child_friendly, safe, notes)
VALUES(
'9b2f6f78-1c6d-4a3c-bb70-2d6b907d8fc6',
'123e4567-e89b-12d3-a456-426614174000',
'f47ac10b-58cc-4372-a567-0e02b2c3d479',
3,
2,
3,
3,
2,
3,
'Excellent option. Felt very safe. No issues!'
);
-- London Central Mosque reviews
INSERT INTO reviews (id, reviewer, place, quiet, clean, clean_wudu, private, child_friendly, safe, notes)
VALUES (
'aa2a3bb7-9a66-4e5c-87db-b2f994f94460',
'123e4567-e89b-12d3-a456-426614174000',
'c1d2b340-a9fb-4e39-9d58-8d537012fc1f',
3, 3, 3, 2, 3, 3,
'Very clean and well-maintained. A bit busy on weekends, but overall peaceful.'
);
INSERT INTO reviews (id, reviewer, place, quiet, clean, clean_wudu, private, child_friendly, safe, notes)
VALUES (
'9f3c6e3a-06d6-4b8c-a5f9-cbf1533a2b20',
'123e4567-e89b-12d3-a456-426614174000',
'c1d2b340-a9fb-4e39-9d58-8d537012fc1f',
2, 3, 2, 1, 3, 3,
'Impressive space but the wudu area can get a bit crowded.'
);
INSERT INTO reviews (id, reviewer, place, quiet, clean, clean_wudu, private, child_friendly, safe, notes)
VALUES (
'1a644da0-57c0-4bba-bb65-195fa32667a6',
'123e4567-e89b-12d3-a456-426614174000',
'c1d2b340-a9fb-4e39-9d58-8d537012fc1f',
3, 3, 3, 3, 3, 3,
'One of the best prayer spaces in London. Womens area is very good too.'
);
--Finsbury park mosque reviews
INSERT INTO reviews (id, reviewer, place, quiet, clean, clean_wudu, private, child_friendly, safe, notes)
VALUES (
'd3ac1e01-9c91-4d3a-93ee-fd52d08eab37',
'123e4567-e89b-12d3-a456-426614174000',
'b8f6f9a2-8b1e-4c01-988f-30c66c3c4b7f',
2, 2, 2, 2, 2, 2,
'Good space but can be quite busy and a little noisy at times.'
);
INSERT INTO reviews (id, reviewer, place, quiet, clean, clean_wudu, private, child_friendly, safe, notes)
VALUES (
'66bc2be3-849e-4127-b6b1-c25465fd6ef0',
'123e4567-e89b-12d3-a456-426614174000',
'b8f6f9a2-8b1e-4c01-988f-30c66c3c4b7f',
3, 2, 2, 2, 2, 3,
'Felt safe and comfortable. Great khutbahs.'
);
--Brick lane mosque reviews
INSERT INTO reviews (id, reviewer, place, quiet, clean, clean_wudu, private, child_friendly, safe, notes)
VALUES (
'82c2f98b-6b59-46e0-b263-ecbdc3054cf1',
'123e4567-e89b-12d3-a456-426614174000',
'e347c2be-338e-41d9-b9b0-dacc5a77fd22',
2, 2, 1, 1, 2, 2,
'Historic site but facilities are limited. Noisy due to location.'
);
INSERT INTO reviews (id, reviewer, place, quiet, clean, clean_wudu, private, child_friendly, safe, notes)
VALUES (
'2cb11b62-8f19-4c6d-a75f-4179f63cc3fa',
'123e4567-e89b-12d3-a456-426614174000',
'e347c2be-338e-41d9-b9b0-dacc5a77fd22',
1, 2, 1, 1, 1, 2,
'Very basic space. No real privacy. Better for quick prayers than extended time.'
);
INSERT INTO reviews (id, reviewer, place, quiet, clean, clean_wudu, private, child_friendly, safe, notes)
VALUES (
'f5e7483a-69f3-4e94-a180-d8d37d64a0b4',
'123e4567-e89b-12d3-a456-426614174000',
'e347c2be-338e-41d9-b9b0-dacc5a77fd22',
2, 2, 2, 2, 2, 2,
'Love the atmosphere but definitely not the cleanest mosque.'
);
--West London Islamic Centre
INSERT INTO reviews (id, reviewer, place, quiet, clean, clean_wudu, private, child_friendly, safe, notes)
VALUES (
'eae0c801-cf0a-42b2-bd39-fb8a54cf5d72',
'123e4567-e89b-12d3-a456-426614174000',
'd47b6d77-9c47-4dd1-8361-b5bdf5f9630f',
3, 3, 3, 3, 3, 3,
'Absolutely wonderful space. Quiet and ideal for focus.'
);
-- Images
-- East London Mosque
INSERT INTO place_images (id, place_id, image_url, notes)
VALUES(
'f3b9a69e-7c4e-4c8c-9b8d-f2e313298dc9',
'f47ac10b-58cc-4372-a567-0e02b2c3d479',
'https://upload.wikimedia.org/wikipedia/commons/c/cc/East_London_Mosque_Front_View.jpg',
'Front view of the East London Mosque and London Muslim Centre.'
);
INSERT INTO place_images (id, place_id, image_url, notes)
VALUES(
'a3c2de90-bfc5-41fa-8c6d-5b3950c0fa2f',
'f47ac10b-58cc-4372-a567-0e02b2c3d479',
'https://prayersconnect.com/api/image-resize/eyJlZGl0cyI6eyJyZXNpemUiOnsid2lkdGgiOjYxNiwiaGVpZ2h0IjozOTJ9fSwidXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL3BsYWNlcy9BQWNYcjhyb3haOS1rZ3FBbHluQTRjT2ZzcGlhLVlhZGRjX3pRV2dSNW1FQ2xKVFpHcEd4U21VLVVMWUFuUEVid3FGdnNNRGhUd0k1Wmc1NS1Kd1BzSW9vSDZuVm1hS0d5Z1JkSS1jPXMxNjAwLXc0MDMyIn0=',
'Interior prayer hall, view towards the Mihrab.'
);
INSERT INTO place_images (id, place_id, image_url, notes)
VALUES(
'54f6b42c-7e4e-4fc3-91a0-bf248aa5a998',
'f47ac10b-58cc-4372-a567-0e02b2c3d479',
'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRhfHiXh7Jl7iHjMy6PF7dn5GgTxHebNFVAAQ&s',
'Women''s gallery providing a separate prayer space.'
);
INSERT INTO place_images (id, place_id, image_url, notes)
VALUES(
'e74a8b59-6ae1-4c6e-9f1e-2b8122e2e2ce',
'f47ac10b-58cc-4372-a567-0e02b2c3d479',
'https://www.eastlondonmosque.org.uk/Handlers/GetImage.ashx?IDMF=7c71af52-2713-4120-bd19-8b03a9b4d313&h=842&src=mc&w=1192',
'Women''s gallery providing a separate prayer space.'
);
-- London Central Mosque (Regent's Park)
INSERT INTO place_images (id, place_id, image_url, notes)
VALUES (
'6a11a9f0-d8f9-4ad6-b77e-d09b987df2f1',
'c1d2b340-a9fb-4e39-9d58-8d537012fc1f',
'https://upload.wikimedia.org/wikipedia/commons/1/13/London_Central_Mosque_2.jpg',
'Exterior view of London Central Mosque near Regents Park.'
);
-- Finsbury Park Mosque
INSERT INTO place_images (id, place_id, image_url, notes)
VALUES (
'9c59a700-1f88-4cd7-97ee-9a53c6cb7ff5',
'b8f6f9a2-8b1e-4c01-988f-30c66c3c4b7f',
'https://upload.wikimedia.org/wikipedia/commons/d/d8/North_London_Central_Mosque%2C_Finsbury_Park_-_geograph.org.uk_-_759870.jpg',
'Street-facing image of Finsbury Park Mosque (North London Central Mosque).'
);
-- Brick Lane Mosque
INSERT INTO place_images (id, place_id, image_url, notes)
VALUES (
'e3fd61f1-0d8b-4f89-8a55-40f4d040b59b',
'e347c2be-338e-41d9-b9b0-dacc5a77fd22',
'https://upload.wikimedia.org/wikipedia/commons/4/4b/Brick_Lane_Mosque2.JPG',
'Historic Brick Lane Mosque building located in East London.'
);
-- West London Islamic Centre
INSERT INTO place_images (id, place_id, image_url, notes)
VALUES (
'ddb1fa3d-8974-4b3e-a0f4-62f4cbcf4080',
'd47b6d77-9c47-4dd1-8361-b5bdf5f9630f',
'https://wlic.co.uk/wp-content/uploads/2019/09/WLIC_Aug_19.jpg',
'Front entrance of the modern West London Islamic Centre.'
);

41
docker-compose.yml Normal file
View File

@ -0,0 +1,41 @@
version: '0.1'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
volumes:
- uploaded_images:/uploads
ports:
- '8080:8080'
environment:
DB_HOST: db
DB_PORT: 5432
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
depends_on:
db:
condition: service_healthy
db:
image: postgres:14
restart: always
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- data:/var/lib/postgresql/data
- ./database/init/01-schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
- ./database/init/02-seed.sql:/docker-entrypoint-initdb.d/02-seed.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
volumes:
data:
uploaded_images:

6
package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "drp-project",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@ -0,0 +1,23 @@
spec:
inputs:
# These are examples of inputs.
job_name:
default: job-template
image:
default: busybox:latest
stage:
default: test
---
# This is an example of a job using inputs
# use variables with this syntax : $[[ inputs.xxx ]]
$[[ inputs.job_name ]]:
image: $[[ inputs.image ]]
stage: $[[ inputs.stage ]]
script:
- echo "Starting job $[[ inputs.job_name ]]"
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- when: always